
In Python 3.7, there's no longer a os.errno, it's now available directly from the root instead. This patch therefore, tries to "import errno", and fallsback to "import os.errno as errno" if it fails. Then in the content of the code, we simply use the errno module directly. Change-Id: Ibf385ab32a8098e936c019303633de38a848076c
318 lines
11 KiB
Python
318 lines
11 KiB
Python
# 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.
|
|
|
|
# NOTE(milan) the filter design relies on the hostdir[1] being in exclusive
|
|
# inspector control. The hostdir should be considered a private cache directory
|
|
# of inspector that dnsmasq has read access to and polls updates from, through
|
|
# the inotify facility.
|
|
#
|
|
# [1] see the --dhcp-hostsdir option description in
|
|
# http://www.thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html
|
|
|
|
|
|
try:
|
|
import errno
|
|
except ImportError:
|
|
import os.errno as errno
|
|
import fcntl
|
|
import os
|
|
import time
|
|
|
|
from oslo_concurrency import processutils
|
|
from oslo_config import cfg
|
|
from oslo_log import log
|
|
from oslo_utils import timeutils
|
|
|
|
from ironic_inspector.common import ironic as ir_utils
|
|
from ironic_inspector import node_cache
|
|
from ironic_inspector.pxe_filter import base as pxe_filter
|
|
|
|
CONF = cfg.CONF
|
|
LOG = log.getLogger(__name__)
|
|
|
|
_EXCLUSIVE_WRITE_ATTEMPTS = 10
|
|
_EXCLUSIVE_WRITE_ATTEMPTS_DELAY = 0.01
|
|
|
|
_ROOTWRAP_COMMAND = 'sudo ironic-inspector-rootwrap {rootwrap_config!s}'
|
|
_MACBL_LEN = len('ff:ff:ff:ff:ff:ff,ignore\n')
|
|
_MACWL_LEN = len('ff:ff:ff:ff:ff:ff\n')
|
|
_UNKNOWN_HOSTS_FILE = 'unknown_hosts_filter'
|
|
_BLACKLIST_UNKNOWN_HOSTS = '*:*:*:*:*:*,ignore\n'
|
|
_WHITELIST_UNKNOWN_HOSTS = '*:*:*:*:*:*\n'
|
|
|
|
|
|
def _should_enable_unknown_hosts():
|
|
"""Check whether we should enable DHCP for unknown hosts
|
|
|
|
We blacklist unknown hosts unless one or more nodes are on introspection
|
|
and node_not_found_hook is not set.
|
|
"""
|
|
return (node_cache.introspection_active() or
|
|
CONF.processing.node_not_found_hook is not None)
|
|
|
|
|
|
class DnsmasqFilter(pxe_filter.BaseFilter):
|
|
"""The dnsmasq PXE filter driver.
|
|
|
|
A pxe filter driver implementation that controls access to dnsmasq
|
|
through amending its configuration.
|
|
"""
|
|
|
|
def reset(self):
|
|
"""Stop dnsmasq and upcall reset."""
|
|
_execute(CONF.dnsmasq_pxe_filter.dnsmasq_stop_command,
|
|
ignore_errors=True)
|
|
super(DnsmasqFilter, self).reset()
|
|
|
|
def _sync(self, ironic):
|
|
"""Sync the inspector, ironic and dnsmasq state. Locked.
|
|
|
|
:raises: IOError, OSError.
|
|
:returns: None.
|
|
"""
|
|
LOG.debug('Syncing the driver')
|
|
timestamp_start = timeutils.utcnow()
|
|
|
|
# active_macs are the MACs for which introspection is active
|
|
active_macs = node_cache.active_macs()
|
|
# ironic_macs are all the MACs know to ironic (all ironic ports)
|
|
ironic_macs = set(port.address for port in
|
|
ir_utils.call_with_retries(ironic.port.list, limit=0,
|
|
fields=['address']))
|
|
blacklist, whitelist = _get_black_white_lists()
|
|
# removedlist are the MACs that are in either blacklist or whitelist,
|
|
# but not kept in ironic (ironic_macs) any more
|
|
removedlist = blacklist.union(whitelist).difference(ironic_macs)
|
|
|
|
# Whitelist active MACs that are not already whitelisted
|
|
for mac in active_macs.difference(whitelist):
|
|
_whitelist_mac(mac)
|
|
# Blacklist any ironic MACs that is not active for introspection unless
|
|
# it is already blacklisted
|
|
for mac in ironic_macs.difference(blacklist.union(active_macs)):
|
|
_blacklist_mac(mac)
|
|
|
|
# Whitelist or Blacklist unknown hosts and MACs not kept in ironic
|
|
# NOTE(hjensas): Treat unknown hosts and MACs not kept in ironic the
|
|
# same. Neither should boot the inspection image unless introspection
|
|
# is active. Deleted MACs must be whitelisted when introspection is
|
|
# active in case the host is re-enrolled.
|
|
_configure_unknown_hosts()
|
|
_configure_removedlist(removedlist)
|
|
|
|
timestamp_end = timeutils.utcnow()
|
|
LOG.debug('The dnsmasq PXE filter was synchronized (took %s)',
|
|
timestamp_end - timestamp_start)
|
|
|
|
@pxe_filter.locked_driver_event(pxe_filter.Events.sync)
|
|
def sync(self, ironic):
|
|
"""Sync dnsmasq configuration with current Ironic&Inspector state.
|
|
|
|
Polls all ironic ports. Those being inspected, the active ones, are
|
|
whitelisted while the rest are blacklisted in the dnsmasq
|
|
configuration.
|
|
|
|
:param ironic: an ironic client instance.
|
|
:raises: OSError, IOError.
|
|
:returns: None.
|
|
"""
|
|
self._sync(ironic)
|
|
|
|
@pxe_filter.locked_driver_event(pxe_filter.Events.initialize)
|
|
def init_filter(self):
|
|
"""Performs an initial sync with ironic and starts dnsmasq.
|
|
|
|
The initial _sync() call reduces the chances dnsmasq might lose
|
|
some inotify blacklist events by prefetching the blacklist before
|
|
the dnsmasq is started.
|
|
|
|
:raises: OSError, IOError.
|
|
:returns: None.
|
|
"""
|
|
_purge_dhcp_hostsdir()
|
|
ironic = ir_utils.get_client()
|
|
self._sync(ironic)
|
|
_execute(CONF.dnsmasq_pxe_filter.dnsmasq_start_command)
|
|
LOG.info('The dnsmasq PXE filter was initialized')
|
|
|
|
|
|
def _purge_dhcp_hostsdir():
|
|
"""Remove all the DHCP hosts files.
|
|
|
|
:raises: FileNotFoundError in case the dhcp_hostsdir is invalid.
|
|
IOError in case of non-writable file or a record not being a file.
|
|
:returns: None.
|
|
"""
|
|
dhcp_hostsdir = CONF.dnsmasq_pxe_filter.dhcp_hostsdir
|
|
if not CONF.dnsmasq_pxe_filter.purge_dhcp_hostsdir:
|
|
LOG.debug('Not purging %s; disabled in configuration.', dhcp_hostsdir)
|
|
return
|
|
|
|
LOG.debug('Purging %s', dhcp_hostsdir)
|
|
for mac in os.listdir(dhcp_hostsdir):
|
|
path = os.path.join(dhcp_hostsdir, mac)
|
|
# NOTE(milan) relying on a failure here aborting the init_filter() call
|
|
os.remove(path)
|
|
LOG.debug('Removed %s', path)
|
|
|
|
|
|
def _get_black_white_lists():
|
|
"""Get addresses currently blacklisted in dnsmasq.
|
|
|
|
:raises: FileNotFoundError in case the dhcp_hostsdir is invalid.
|
|
:returns: a set of MACs currently blacklisted in dnsmasq.
|
|
"""
|
|
hostsdir = CONF.dnsmasq_pxe_filter.dhcp_hostsdir
|
|
# whitelisted MACs lack the ,ignore directive
|
|
blacklist = set()
|
|
whitelist = set()
|
|
for mac in os.listdir(hostsdir):
|
|
if os.stat(os.path.join(hostsdir, mac)).st_size == _MACBL_LEN:
|
|
blacklist.add(mac)
|
|
if os.stat(os.path.join(hostsdir, mac)).st_size == _MACWL_LEN:
|
|
whitelist.add(mac)
|
|
|
|
return blacklist, whitelist
|
|
|
|
|
|
def _exclusive_write_or_pass(path, buf):
|
|
"""Write exclusively or pass if path locked.
|
|
|
|
The intention is to be able to run multiple instances of the filter on the
|
|
same node in multiple inspector processes.
|
|
|
|
:param path: where to write to
|
|
:param buf: the content to write
|
|
:raises: FileNotFoundError, IOError
|
|
:returns: True if the write was successful.
|
|
"""
|
|
# NOTE(milan) line-buffering enforced to ensure dnsmasq record update
|
|
# through inotify, which reacts on f.close()
|
|
attempts = _EXCLUSIVE_WRITE_ATTEMPTS
|
|
with open(path, 'w', 1) as f:
|
|
while attempts:
|
|
try:
|
|
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
f.write(buf)
|
|
# Go ahead and flush the data now instead of waiting until
|
|
# after the automatic flush with the file close after the
|
|
# file lock is released.
|
|
f.flush()
|
|
return True
|
|
except IOError as e:
|
|
if e.errno == errno.EWOULDBLOCK:
|
|
LOG.debug('%s locked; will try again (later)', path)
|
|
attempts -= 1
|
|
time.sleep(_EXCLUSIVE_WRITE_ATTEMPTS_DELAY)
|
|
continue
|
|
raise
|
|
finally:
|
|
fcntl.flock(f, fcntl.LOCK_UN)
|
|
LOG.debug('Failed to write the exclusively-locked path: %(path)s for '
|
|
'%(attempts)s times', {'attempts': _EXCLUSIVE_WRITE_ATTEMPTS,
|
|
'path': path})
|
|
return False
|
|
|
|
|
|
def _configure_removedlist(macs):
|
|
"""Manages a dhcp_hostsdir ignore/not-ignore record for removed macs
|
|
|
|
:raises: FileNotFoundError in case the dhcp_hostsdir is invalid,
|
|
:returns: None.
|
|
"""
|
|
|
|
hostsdir = CONF.dnsmasq_pxe_filter.dhcp_hostsdir
|
|
|
|
if _should_enable_unknown_hosts():
|
|
for mac in macs:
|
|
if os.stat(os.path.join(hostsdir, mac)).st_size != _MACWL_LEN:
|
|
_whitelist_mac(mac)
|
|
else:
|
|
for mac in macs:
|
|
if os.stat(os.path.join(hostsdir, mac)).st_size != _MACBL_LEN:
|
|
_blacklist_mac(mac)
|
|
|
|
|
|
def _configure_unknown_hosts():
|
|
"""Manages a dhcp_hostsdir ignore/not-ignore record for unknown macs.
|
|
|
|
:raises: FileNotFoundError in case the dhcp_hostsdir is invalid,
|
|
IOError in case the dhcp host unknown file isn't writable.
|
|
:returns: None.
|
|
"""
|
|
path = os.path.join(CONF.dnsmasq_pxe_filter.dhcp_hostsdir,
|
|
_UNKNOWN_HOSTS_FILE)
|
|
|
|
if _should_enable_unknown_hosts():
|
|
wildcard_filter = _WHITELIST_UNKNOWN_HOSTS
|
|
log_wildcard_filter = 'whitelist'
|
|
else:
|
|
wildcard_filter = _BLACKLIST_UNKNOWN_HOSTS
|
|
log_wildcard_filter = 'blacklist'
|
|
|
|
# Don't update if unknown hosts are already black/white-listed
|
|
try:
|
|
if os.stat(path).st_size == len(wildcard_filter):
|
|
return
|
|
except OSError as e:
|
|
if e.errno != errno.ENOENT:
|
|
raise
|
|
|
|
if _exclusive_write_or_pass(path, '%s' % wildcard_filter):
|
|
LOG.debug('A %s record for all unknown hosts using wildcard mac '
|
|
'created', log_wildcard_filter)
|
|
else:
|
|
LOG.warning('Failed to %s unknown hosts using wildcard mac; '
|
|
'retrying next periodic sync time', log_wildcard_filter)
|
|
|
|
|
|
def _blacklist_mac(mac):
|
|
"""Creates a dhcp_hostsdir ignore record for the MAC.
|
|
|
|
:raises: FileNotFoundError in case the dhcp_hostsdir is invalid,
|
|
IOError in case the dhcp host MAC file isn't writable.
|
|
:returns: None.
|
|
"""
|
|
path = os.path.join(CONF.dnsmasq_pxe_filter.dhcp_hostsdir, mac)
|
|
if _exclusive_write_or_pass(path, '%s,ignore\n' % mac):
|
|
LOG.debug('Blacklisted %s', mac)
|
|
else:
|
|
LOG.warning('Failed to blacklist %s; retrying next periodic sync '
|
|
'time', mac)
|
|
|
|
|
|
def _whitelist_mac(mac):
|
|
"""Un-ignores the dhcp_hostsdir record for the MAC.
|
|
|
|
:raises: FileNotFoundError in case the dhcp_hostsdir is invalid,
|
|
IOError in case the dhcp host MAC file isn't writable.
|
|
:returns: None.
|
|
"""
|
|
path = os.path.join(CONF.dnsmasq_pxe_filter.dhcp_hostsdir, mac)
|
|
# remove the ,ignore directive
|
|
if _exclusive_write_or_pass(path, '%s\n' % mac):
|
|
LOG.debug('Whitelisted %s', mac)
|
|
else:
|
|
LOG.warning('Failed to whitelist %s; retrying next periodic sync '
|
|
'time', mac)
|
|
|
|
|
|
def _execute(cmd=None, ignore_errors=False):
|
|
# e.g: '/bin/kill $(cat /var/run/dnsmasq.pid)'
|
|
if not cmd:
|
|
return
|
|
|
|
helper = _ROOTWRAP_COMMAND.format(rootwrap_config=CONF.rootwrap_config)
|
|
processutils.execute(cmd, run_as_root=True, root_helper=helper, shell=True,
|
|
check_exit_code=not ignore_errors)
|