PXE boot filtering drivers

Introduce a driver concept for PXE filtering

Change-Id: I73297771c4118f368b80a5f1021a0d5c3fc8b96e
Closes-Bug: 1665666
This commit is contained in:
dparalen 2017-06-06 19:21:23 +02:00
parent c172b2eaf0
commit e02bc755a6
8 changed files with 635 additions and 0 deletions

View File

@ -316,3 +316,52 @@ the database::
.. _Create a Migration Script: http://alembic.zzzcomputing.com/en/latest/tutorial.html#create-a-migration-script .. _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 .. _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 .. _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`.

View File

@ -795,6 +795,21 @@
#power_off = true #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] [swift]
# #

View File

@ -199,10 +199,18 @@ SERVICE_OPTS = [
help=_('Limit the number of elements an API list-call returns')) 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(SERVICE_OPTS)
cfg.CONF.register_opts(FIREWALL_OPTS, group='firewall') cfg.CONF.register_opts(FIREWALL_OPTS, group='firewall')
cfg.CONF.register_opts(PROCESSING_OPTS, group='processing') cfg.CONF.register_opts(PROCESSING_OPTS, group='processing')
cfg.CONF.register_opts(PXE_FILTER_OPTS, 'pxe_filter')
def list_opts(): def list_opts():
@ -210,6 +218,7 @@ def list_opts():
('', SERVICE_OPTS), ('', SERVICE_OPTS),
('firewall', FIREWALL_OPTS), ('firewall', FIREWALL_OPTS),
('processing', PROCESSING_OPTS), ('processing', PROCESSING_OPTS),
('pxe_filter', PXE_FILTER_OPTS),
] ]

View File

View File

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

View File

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

View File

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

View File

@ -57,6 +57,8 @@ ironic_inspector.rules.actions =
set-attribute = ironic_inspector.plugins.rules:SetAttributeAction set-attribute = ironic_inspector.plugins.rules:SetAttributeAction
set-capability = ironic_inspector.plugins.rules:SetCapabilityAction set-capability = ironic_inspector.plugins.rules:SetCapabilityAction
extend-attribute = ironic_inspector.plugins.rules:ExtendAttributeAction extend-attribute = ironic_inspector.plugins.rules:ExtendAttributeAction
ironic_inspector.pxe_filter =
noop = ironic_inspector.pxe_filter.base:NoopFilter
oslo.config.opts = oslo.config.opts =
ironic_inspector = ironic_inspector.conf:list_opts ironic_inspector = ironic_inspector.conf:list_opts
ironic_inspector.common.ironic = ironic_inspector.common.ironic:list_opts ironic_inspector.common.ironic = ironic_inspector.common.ironic:list_opts