From e02bc755a64a6be29fb80719399d1c5dffb45b50 Mon Sep 17 00:00:00 2001 From: dparalen Date: Tue, 6 Jun 2017 19:21:23 +0200 Subject: [PATCH] PXE boot filtering drivers Introduce a driver concept for PXE filtering Change-Id: I73297771c4118f368b80a5f1021a0d5c3fc8b96e Closes-Bug: 1665666 --- CONTRIBUTING.rst | 49 ++++ example.conf | 15 + ironic_inspector/conf.py | 9 + ironic_inspector/pxe_filter/__init__.py | 0 ironic_inspector/pxe_filter/base.py | 224 +++++++++++++++ ironic_inspector/pxe_filter/interface.py | 64 +++++ ironic_inspector/test/unit/test_pxe_filter.py | 272 ++++++++++++++++++ setup.cfg | 2 + 8 files changed, 635 insertions(+) create mode 100644 ironic_inspector/pxe_filter/__init__.py create mode 100644 ironic_inspector/pxe_filter/base.py create mode 100644 ironic_inspector/pxe_filter/interface.py create mode 100644 ironic_inspector/test/unit/test_pxe_filter.py diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 2b24c0080..94d13147d 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -316,3 +316,52 @@ the database:: .. _Create a Migration Script: http://alembic.zzzcomputing.com/en/latest/tutorial.html#create-a-migration-script .. _ironic_inspector.db: http://docs.openstack.org/developer/ironic-inspector/api/ironic_inspector.db.html .. _What does Autogenerate Detect (and what does it not detect?): http://alembic.zzzcomputing.com/en/latest/autogenerate.html#what-does-autogenerate-detect-and-what-does-it-not-detect + +Implementing PXE Filter Drivers +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Background +---------- + +**inspector** in-band introspection PXE-boots the Ironic Python Agent "live" +image, to inspect the baremetal server. **ironic** also PXE-boots IPA to +perform tasks on a node, such as deploying an image. **ironic** uses +**neutron** to provide DHCP, however **neutron** does not provide DHCP for +unknown MAC addresses so **inspector** has to use its own DHCP/TFTP stack for +discovery and inspection. + +When **ironic** and **inspector** are operating in the same L2 network, there +is a potential for the two DHCPs to race, which could result in a node being +deployed by **ironic** being PXE booted by **inspector**. + +To prevent DHCP races between the **inspector** DHCP and **ironic** DHCP, +**inspector** has to be able to filter which nodes can get a DHCP lease from +the **inspector** DHCP server. These filters can then be used to prevent +node's enrolled in **ironic** inventory from being PXE-booted unless they are +explicitly moved into the ``inspected`` state. + +Filter Interface +---------------- + +.. py:currentmodule:: ironic_inspector.pxe_filter.interface + +The contract between **inspector** and a PXE filter driver is described in the +:class:`FilterDriver` interface. The methods a driver has to implement are: + +* :meth:`~FilterDriver.init_filter` called on the service start to initialize + internal driver state + +* :meth:`~FilterDriver.sync` called both periodically and when a node starts or + finishes introspection to white or blacklist its ports MAC addresses in the + driver + +* :meth:`~FilterDriver.tear_down_filter` called on service exit to reset the + internal driver state + +.. py:currentmodule:: ironic_inspector.pxe_filter.base + +The driver-specific configuration is suggested to be parsed during +instantiation. There's also a convenience generic interface implementation +:class:`BaseFilter` that provides base locking and initialization +implementation. If required, a driver can opt-out from the periodic +synchronization by overriding the :meth:`~BaseFilter.get_periodic_sync_task`. diff --git a/example.conf b/example.conf index 76312314a..822b3a96c 100644 --- a/example.conf +++ b/example.conf @@ -795,6 +795,21 @@ #power_off = true +[pxe_filter] + +# +# From ironic_inspector +# + +# PXE boot filter driver to use, such as iptables (string value) +#driver = noop + +# Amount of time in seconds, after which repeat periodic update of the +# filter. (integer value) +# Minimum value: 0 +#sync_period = 15 + + [swift] # diff --git a/ironic_inspector/conf.py b/ironic_inspector/conf.py index 1a8b9dc1a..0f49f0100 100644 --- a/ironic_inspector/conf.py +++ b/ironic_inspector/conf.py @@ -199,10 +199,18 @@ SERVICE_OPTS = [ help=_('Limit the number of elements an API list-call returns')) ] +PXE_FILTER_OPTS = [ + cfg.StrOpt('driver', default='noop', + help=_('PXE boot filter driver to use, such as iptables')), + cfg.IntOpt('sync_period', default=15, min=0, + help=_('Amount of time in seconds, after which repeat periodic ' + 'update of the filter.')), +] cfg.CONF.register_opts(SERVICE_OPTS) cfg.CONF.register_opts(FIREWALL_OPTS, group='firewall') cfg.CONF.register_opts(PROCESSING_OPTS, group='processing') +cfg.CONF.register_opts(PXE_FILTER_OPTS, 'pxe_filter') def list_opts(): @@ -210,6 +218,7 @@ def list_opts(): ('', SERVICE_OPTS), ('firewall', FIREWALL_OPTS), ('processing', PROCESSING_OPTS), + ('pxe_filter', PXE_FILTER_OPTS), ] diff --git a/ironic_inspector/pxe_filter/__init__.py b/ironic_inspector/pxe_filter/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ironic_inspector/pxe_filter/base.py b/ironic_inspector/pxe_filter/base.py new file mode 100644 index 000000000..e49fc4c67 --- /dev/null +++ b/ironic_inspector/pxe_filter/base.py @@ -0,0 +1,224 @@ +# 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. + +"""Base code for PXE boot filtering.""" + +import contextlib +import functools + +from automaton import exceptions as automaton_errors +from automaton import machines +from eventlet import semaphore +from futurist import periodics +from oslo_concurrency import lockutils +from oslo_config import cfg +from oslo_log import log +import stevedore + +from ironic_inspector.common.i18n import _ +from ironic_inspector.common import ironic as ir_utils +from ironic_inspector.pxe_filter import interface + +CONF = cfg.CONF +LOG = log.getLogger(__name__) + +_STEVEDORE_DRIVER_NAMESPACE = 'ironic_inspector.pxe_filter' + + +class InvalidFilterDriverState(RuntimeError): + """The fsm of the filter driver raised an error.""" + + +class States(object): + """PXE filter driver states.""" + uninitialized = 'uninitialized' + initialized = 'initialized' + + +class Events(object): + """PXE filter driver transitions.""" + initialize = 'initialize' + sync = 'sync' + reset = 'reset' + + +# a reset is always possible +State_space = [ + { + 'name': States.uninitialized, + 'next_states': { + Events.initialize: States.initialized, + Events.reset: States.uninitialized, + }, + }, + { + 'name': States.initialized, + 'next_states': { + Events.sync: States.initialized, + Events.reset: States.uninitialized, + }, + }, +] + + +def locked_driver_event(event): + """Call driver method having processed the fsm event.""" + def outer(method): + @functools.wraps(method) + def inner(self, *args, **kwargs): + with self.lock, self.fsm_reset_on_error() as fsm: + fsm.process_event(event) + return method(self, *args, **kwargs) + return inner + return outer + + +class BaseFilter(interface.FilterDriver): + """The generic PXE boot filtering interface implementation. + + This driver doesn't do anything but provides a basic synchronization and + initialization logic for some drivers to reuse. Subclasses have to provide + a custom sync() method. + """ + + fsm = machines.FiniteMachine.build(State_space) + fsm.default_start_state = States.uninitialized + + def __init__(self): + super(BaseFilter, self).__init__() + self.lock = semaphore.BoundedSemaphore() + self.fsm.initialize(start_state=States.uninitialized) + + def __str__(self): + return '%(driver)s, state=%(state)s' % { + 'driver': type(self).__name__, 'state': self.state} + + @property + def state(self): + """Current driver state.""" + return self.fsm.current_state + + def reset(self): + """Reset internal driver state. + + This method is called by the fsm_context manager upon exception as well + as by the tear_down_filter method. A subclass might wish to override as + necessary, though must not lock the driver. The overriding subclass + should up-call. + + :returns: nothing. + """ + LOG.debug('Resetting the PXE filter driver %s', self) + # a reset event is always possible + self.fsm.process_event(Events.reset) + + @contextlib.contextmanager + def fsm_reset_on_error(self): + """Reset the filter driver upon generic exception. + + The context is self.fsm. The automaton.exceptions.NotFound error is + cast to the InvalidFilterDriverState error. Other exceptions trigger + self.reset() + + :raises: InvalidFilterDriverState + :returns: nothing. + """ + LOG.debug('The PXE filter driver %s enters the fsm_reset_on_error ' + 'context', self) + try: + yield self.fsm + except automaton_errors.NotFound as e: + raise InvalidFilterDriverState(_('The PXE filter driver %(driver)s' + ': my fsm encountered an ' + 'exception: %(error)s') % { + 'driver': self, 'error': e}) + except Exception as e: + LOG.exception('The PXE filter %(filter)s encountered an ' + 'exception: %(error)s; resetting the filter', + {'filter': self, 'error': e}) + self.reset() + raise + finally: + LOG.debug('The PXE filter driver %s left the fsm_reset_on_error ' + 'context', self) + + @locked_driver_event(Events.initialize) + def init_filter(self): + """Base driver initialization logic. Locked. + + :raises: InvalidFilterDriverState + :returns: nothing. + """ + LOG.debug('Initializing the PXE filter driver %s', self) + + def tear_down_filter(self): + """Base driver tear down logic. Locked. + + :returns: nothing. + """ + LOG.debug('Tearing down the PXE filter driver %s', self) + with self.lock: + self.reset() + + @locked_driver_event(Events.sync) + def sync(self, ironic): + """Base driver sync logic. Locked. + + :param ironic: obligatory ironic client instance + :returns: nothing. + """ + LOG.debug('Syncing the PXE filter driver %s', self) + + def get_periodic_sync_task(self): + """Get periodic sync task for the filter. + + :returns: a periodic task to be run in the background. + """ + ironic = ir_utils.get_client() + return periodics.periodic( + # NOTE(milan): the periodic decorator doesn't support 0 as + # a spacing value of (a switched off) periodic + spacing=CONF.pxe_filter.sync_period or float('inf'), + enabled=bool(CONF.pxe_filter.sync_period))( + lambda: self.sync(ironic)) + + +class NoopFilter(BaseFilter): + """A trivial PXE boot filter.""" + + +_DRIVER_MANAGER = None + + +@lockutils.synchronized(__name__) +def _driver_manager(): + """Create a Stevedore driver manager for filtering drivers. Locked.""" + global _DRIVER_MANAGER + + name = CONF.pxe_filter.driver + if _DRIVER_MANAGER is None: + _DRIVER_MANAGER = stevedore.driver.DriverManager( + _STEVEDORE_DRIVER_NAMESPACE, + name=name, + invoke_on_load=True + ) + + return _DRIVER_MANAGER + + +def driver(): + """Get the driver for the PXE filter. + + :returns: the singleton PXE filter driver object. + """ + return _driver_manager().driver diff --git a/ironic_inspector/pxe_filter/interface.py b/ironic_inspector/pxe_filter/interface.py new file mode 100644 index 000000000..ec1950255 --- /dev/null +++ b/ironic_inspector/pxe_filter/interface.py @@ -0,0 +1,64 @@ +# 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. + +"""The code of the PXE boot filtering interface.""" + +import abc + +import six + + +@six.add_metaclass(abc.ABCMeta) +class FilterDriver(object): + """The PXE boot filtering interface.""" + + @abc.abstractmethod + def init_filter(self): + """Initialize the internal driver state. + + This method should be idempotent and may perform system-wide filter + state changes. Can be synchronous. + + :returns: nothing. + """ + + @abc.abstractmethod + def sync(self, ironic): + """Synchronize the filter with ironic and inspector. + + To be called both periodically and as needed by inspector. The filter + should tear down its internal state if the sync method raises in order + to "propagate" filtering exception between periodic and on-demand sync + call. To this end, a driver should raise from the sync call if its + internal state isn't properly initialized. + + :param ironic: an ironic client instance. + :returns: nothing. + """ + + @abc.abstractmethod + def tear_down_filter(self): + """Reset the filter. + + This method should be idempotent and may perform system-wide filter + state changes. Can be synchronous. + + :returns: nothing. + """ + + @abc.abstractmethod + def get_periodic_sync_task(self): + """Get periodic sync task for the filter. + + :returns: a periodic task to be run in the background. + """ diff --git a/ironic_inspector/test/unit/test_pxe_filter.py b/ironic_inspector/test/unit/test_pxe_filter.py new file mode 100644 index 000000000..b95432559 --- /dev/null +++ b/ironic_inspector/test/unit/test_pxe_filter.py @@ -0,0 +1,272 @@ +# 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 fixtures +import mock +import six +import stevedore + +from automaton import exceptions as automaton_errors +from eventlet import semaphore +from futurist import periodics +from oslo_config import cfg + +from ironic_inspector.common import ironic as ir_utils +from ironic_inspector.pxe_filter import base as pxe_filter +from ironic_inspector.pxe_filter import interface +from ironic_inspector.test import base as test_base + +CONF = cfg.CONF + + +class TestDriverManager(test_base.BaseTest): + def setUp(self): + super(TestDriverManager, self).setUp() + pxe_filter._DRIVER_MANAGER = None + stevedore_driver_fixture = self.useFixture(fixtures.MockPatchObject( + stevedore.driver, 'DriverManager', autospec=True)) + self.stevedore_driver_mock = stevedore_driver_fixture.mock + + def test_default(self): + driver_manager = pxe_filter._driver_manager() + self.stevedore_driver_mock.assert_called_once_with( + pxe_filter._STEVEDORE_DRIVER_NAMESPACE, + name='noop', + invoke_on_load=True + ) + self.assertIsNotNone(driver_manager) + self.assertIs(pxe_filter._DRIVER_MANAGER, driver_manager) + + def test_pxe_filter_name(self): + CONF.set_override('driver', 'foo', 'pxe_filter') + driver_manager = pxe_filter._driver_manager() + self.stevedore_driver_mock.assert_called_once_with( + pxe_filter._STEVEDORE_DRIVER_NAMESPACE, + 'foo', + invoke_on_load=True + ) + self.assertIsNotNone(driver_manager) + self.assertIs(pxe_filter._DRIVER_MANAGER, driver_manager) + + def test_default_existing_driver_manager(self): + pxe_filter._DRIVER_MANAGER = True + driver_manager = pxe_filter._driver_manager() + self.stevedore_driver_mock.assert_not_called() + self.assertIs(pxe_filter._DRIVER_MANAGER, driver_manager) + + +class TestDriverManagerLoading(test_base.BaseTest): + def setUp(self): + super(TestDriverManagerLoading, self).setUp() + pxe_filter._DRIVER_MANAGER = None + + @mock.patch.object(pxe_filter, 'NoopFilter', autospec=True) + def test_pxe_filter_driver_loads(self, noop_driver_cls): + CONF.set_override('driver', 'noop', 'pxe_filter') + driver_manager = pxe_filter._driver_manager() + noop_driver_cls.assert_called_once_with() + self.assertIs(noop_driver_cls.return_value, driver_manager.driver) + + def test_invalid_filter_driver(self): + CONF.set_override('driver', 'foo', 'pxe_filter') + six.assertRaisesRegex(self, stevedore.exception.NoMatches, 'foo', + pxe_filter._driver_manager) + self.assertIsNone(pxe_filter._DRIVER_MANAGER) + + +class BaseFilterBaseTest(test_base.BaseTest): + def setUp(self): + super(BaseFilterBaseTest, self).setUp() + self.mock_lock = mock.MagicMock(spec=semaphore.BoundedSemaphore) + self.mock_bounded_semaphore = self.useFixture( + fixtures.MockPatchObject(semaphore, 'BoundedSemaphore')).mock + self.mock_bounded_semaphore.return_value = self.mock_lock + self.driver = pxe_filter.NoopFilter() + + def assert_driver_is_locked(self): + """Assert the driver is currently locked and wasn't locked before.""" + self.driver.lock.__enter__.assert_called_once_with() + self.driver.lock.__exit__.assert_not_called() + + def assert_driver_was_locked_once(self): + """Assert the driver was locked exactly once before.""" + self.driver.lock.__enter__.assert_called_once_with() + self.driver.lock.__exit__.assert_called_once_with(None, None, None) + + def assert_driver_was_not_locked(self): + """Assert the driver was not locked""" + self.mock_lock.__enter__.assert_not_called() + self.mock_lock.__exit__.assert_not_called() + + +class TestLockedDriverEvent(BaseFilterBaseTest): + def setUp(self): + super(TestLockedDriverEvent, self).setUp() + self.mock_fsm_reset_on_error = self.useFixture( + fixtures.MockPatchObject(self.driver, 'fsm_reset_on_error')).mock + self.expected_args = (None,) + self.expected_kwargs = {'foo': None} + self.mock_fsm = self.useFixture( + fixtures.MockPatchObject(self.driver, 'fsm')).mock + (self.driver.fsm_reset_on_error.return_value. + __enter__.return_value) = self.mock_fsm + + def test_locked_driver_event(self): + event = 'foo' + + @pxe_filter.locked_driver_event(event) + def fun(driver, *args, **kwargs): + self.assertIs(self.driver, driver) + self.assertEqual(self.expected_args, args) + self.assertEqual(self.expected_kwargs, kwargs) + self.assert_driver_is_locked() + + self.assert_driver_was_not_locked() + fun(self.driver, *self.expected_args, **self.expected_kwargs) + + self.mock_fsm_reset_on_error.assert_called_once_with() + self.mock_fsm.process_event.assert_called_once_with(event) + self.assert_driver_was_locked_once() + + +class TestBaseFilterFsmPrecautions(BaseFilterBaseTest): + def setUp(self): + super(TestBaseFilterFsmPrecautions, self).setUp() + self.mock_fsm = self.useFixture( + fixtures.MockPatchObject(pxe_filter.NoopFilter, 'fsm')).mock + # NOTE(milan): overriding driver so that the patch ^ is applied + self.mock_bounded_semaphore.reset_mock() + self.driver = pxe_filter.NoopFilter() + self.mock_reset = self.useFixture( + fixtures.MockPatchObject(self.driver, 'reset')).mock + + def test___init__(self): + self.assertIs(self.mock_lock, self.driver.lock) + self.mock_bounded_semaphore.assert_called_once_with() + self.assertIs(self.mock_fsm, self.driver.fsm) + self.mock_fsm.initialize.assert_called_once_with( + start_state=pxe_filter.States.uninitialized) + + def test_fsm_reset_on_error(self): + with self.driver.fsm_reset_on_error() as fsm: + self.assertIs(self.mock_fsm, fsm) + + self.mock_reset.assert_not_called() + + def test_fsm_automaton_error(self): + + def fun(): + with self.driver.fsm_reset_on_error(): + raise automaton_errors.NotFound('Oops!') + + self.assertRaisesRegex(pxe_filter.InvalidFilterDriverState, + '.*NoopFilter.*Oops!', fun) + self.mock_reset.assert_not_called() + + def test_fsm_reset_on_error_ctx_custom_error(self): + + class MyError(Exception): + pass + + def fun(): + with self.driver.fsm_reset_on_error(): + raise MyError('Oops!') + + self.assertRaisesRegex(MyError, 'Oops!', fun) + self.mock_reset.assert_called_once_with() + + +class TestBaseFilterInterface(BaseFilterBaseTest): + def setUp(self): + super(TestBaseFilterInterface, self).setUp() + self.mock_get_client = self.useFixture( + fixtures.MockPatchObject(ir_utils, 'get_client')).mock + self.mock_ironic = mock.Mock() + self.mock_get_client.return_value = self.mock_ironic + self.mock_periodic = self.useFixture( + fixtures.MockPatchObject(periodics, 'periodic')).mock + self.mock_reset = self.useFixture( + fixtures.MockPatchObject(self.driver, 'reset')).mock + self.mock_log = self.useFixture( + fixtures.MockPatchObject(pxe_filter, 'LOG')).mock + self.driver.fsm_reset_on_error = self.useFixture( + fixtures.MockPatchObject(self.driver, 'fsm_reset_on_error')).mock + + def test_init_filter(self): + self.driver.init_filter() + + self.mock_log.debug.assert_called_once_with( + 'Initializing the PXE filter driver %s', self.driver) + self.mock_reset.assert_not_called() + + def test_sync(self): + self.driver.sync(self.mock_ironic) + + self.mock_log.debug.assert_called_once_with( + 'Syncing the PXE filter driver %s', self.driver) + self.mock_reset.assert_not_called() + + def test_tear_down_filter(self): + self.assert_driver_was_not_locked() + self.driver.tear_down_filter() + + self.assert_driver_was_locked_once() + self.mock_reset.assert_called_once_with() + + def test_get_periodic_sync_task(self): + sync_mock = self.useFixture( + fixtures.MockPatchObject(self.driver, 'sync')).mock + self.driver.get_periodic_sync_task() + self.mock_periodic.assert_called_once_with(spacing=15, enabled=True) + self.mock_periodic.return_value.call_args[0][0]() + sync_mock.assert_called_once_with(self.mock_get_client.return_value) + + def test_get_periodic_sync_task_disabled(self): + CONF.set_override('sync_period', 0, 'pxe_filter') + self.driver.get_periodic_sync_task() + self.mock_periodic.assert_called_once_with(spacing=float('inf'), + enabled=False) + + def test_get_periodic_sync_task_custom_spacing(self): + CONF.set_override('sync_period', 4224, 'pxe_filter') + self.driver.get_periodic_sync_task() + self.mock_periodic.assert_called_once_with(spacing=4224, enabled=True) + + +class TestDriverReset(BaseFilterBaseTest): + def setUp(self): + super(TestDriverReset, self).setUp() + self.mock_fsm = self.useFixture( + fixtures.MockPatchObject(self.driver, 'fsm')).mock + + def test_reset(self): + self.driver.reset() + + self.assert_driver_was_not_locked() + self.mock_fsm.process_event.assert_called_once_with( + pxe_filter.Events.reset) + + +class TestDriver(test_base.BaseTest): + def setUp(self): + super(TestDriver, self).setUp() + self.mock_driver = mock.Mock(spec=interface.FilterDriver) + self.mock__driver_manager = self.useFixture( + fixtures.MockPatchObject(pxe_filter, '_driver_manager')).mock + self.mock__driver_manager.return_value.driver = self.mock_driver + + def test_driver(self): + ret = pxe_filter.driver() + + self.assertIs(self.mock_driver, ret) + self.mock__driver_manager.assert_called_once_with() diff --git a/setup.cfg b/setup.cfg index 9398fc356..2e85ee1b0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -57,6 +57,8 @@ ironic_inspector.rules.actions = set-attribute = ironic_inspector.plugins.rules:SetAttributeAction set-capability = ironic_inspector.plugins.rules:SetCapabilityAction extend-attribute = ironic_inspector.plugins.rules:ExtendAttributeAction +ironic_inspector.pxe_filter = + noop = ironic_inspector.pxe_filter.base:NoopFilter oslo.config.opts = ironic_inspector = ironic_inspector.conf:list_opts ironic_inspector.common.ironic = ironic_inspector.common.ironic:list_opts