1
0
Fork 0
nvme-stas/staslib/conf.py
Daniel Baumann a8f39c03aa
Merging upstream version 2.3.1:
- properly handles big-endian data in `iputils.py` (Closes: #1057031).

Signed-off-by: Daniel Baumann <daniel@debian.org>
2025-02-16 12:56:36 +01:00

806 lines
30 KiB
Python

# Copyright (c) 2022, Dell Inc. or its subsidiaries. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# See the LICENSE file for details.
#
# This file is part of NVMe STorage Appliance Services (nvme-stas).
#
# Authors: Martin Belanger <Martin.Belanger@dell.com>
#
'''nvme-stas configuration module'''
import re
import os
import sys
import logging
import functools
import configparser
from urllib.parse import urlparse
from staslib import defs, iputil, nbft, singleton, timeparse
__TOKEN_RE = re.compile(r'\s*;\s*')
__OPTION_RE = re.compile(r'\s*=\s*')
class InvalidOption(Exception):
'''Exception raised when an invalid option value is detected'''
def _parse_controller(controller):
'''@brief Parse a "controller" entry. Controller entries are strings
composed of several configuration parameters delimited by
semi-colons. Each configuration parameter is specified as a
"key=value" pair.
@return A dictionary of key-value pairs.
'''
options = dict()
tokens = __TOKEN_RE.split(controller)
for token in tokens:
if token:
try:
option, val = __OPTION_RE.split(token)
options[option.strip()] = val.strip()
except ValueError:
pass
return options
def _parse_single_val(text):
if isinstance(text, str):
return text
if not isinstance(text, list) or len(text) == 0:
return None
return text[-1]
def _parse_list(text):
return text if isinstance(text, list) else [text]
def _to_int(text):
try:
return int(_parse_single_val(text))
except (ValueError, TypeError):
raise InvalidOption # pylint: disable=raise-missing-from
def _to_bool(text, positive='true'):
return _parse_single_val(text).lower() == positive
def _to_ncc(text):
value = _to_int(text)
if value == 1: # 1 is invalid. A minimum of 2 is required (with the exception of 0, which is valid).
value = 2
return value
def _to_ip_family(text):
return tuple((4 if text == 'ipv4' else 6 for text in _parse_single_val(text).split('+')))
# ******************************************************************************
class OrderedMultisetDict(dict):
'''This class is used to change the behavior of configparser.ConfigParser
and allow multiple configuration parameters with the same key. The
result is a list of values, where values are sorted by the order they
appear in the file.
'''
def __setitem__(self, key, value):
if key in self and isinstance(value, list):
self[key].extend(value)
else:
super().__setitem__(key, value)
def __getitem__(self, key):
value = super().__getitem__(key)
if isinstance(value, str):
return value.split('\n')
return value
class SvcConf(metaclass=singleton.Singleton): # pylint: disable=too-many-public-methods
'''Read and cache configuration file.'''
OPTION_CHECKER = {
'Global': {
'tron': {
'convert': _to_bool,
'default': False,
'txt-chk': lambda text: _parse_single_val(text).lower() in ('false', 'true'),
},
'kato': {
'convert': _to_int,
},
'pleo': {
'convert': functools.partial(_to_bool, positive='enabled'),
'default': True,
'txt-chk': lambda text: _parse_single_val(text).lower() in ('disabled', 'enabled'),
},
'ip-family': {
'convert': _to_ip_family,
'default': (4, 6),
'txt-chk': lambda text: _parse_single_val(text) in ('ipv4', 'ipv6', 'ipv4+ipv6', 'ipv6+ipv4'),
},
'queue-size': {
'convert': _to_int,
'rng-chk': lambda value: None if value in range(16, 1025) else range(16, 1025),
},
'hdr-digest': {
'convert': _to_bool,
'default': False,
'txt-chk': lambda text: _parse_single_val(text).lower() in ('false', 'true'),
},
'data-digest': {
'convert': _to_bool,
'default': False,
'txt-chk': lambda text: _parse_single_val(text).lower() in ('false', 'true'),
},
'ignore-iface': {
'convert': _to_bool,
'default': False,
'txt-chk': lambda text: _parse_single_val(text).lower() in ('false', 'true'),
},
'nr-io-queues': {
'convert': _to_int,
},
'ctrl-loss-tmo': {
'convert': _to_int,
},
'disable-sqflow': {
'convert': _to_bool,
'txt-chk': lambda text: _parse_single_val(text).lower() in ('false', 'true'),
},
'nr-poll-queues': {
'convert': _to_int,
},
'nr-write-queues': {
'convert': _to_int,
},
'reconnect-delay': {
'convert': _to_int,
},
### BEGIN: LEGACY SECTION TO BE REMOVED ###
'persistent-connections': {
'convert': _to_bool,
'default': False,
'txt-chk': lambda text: _parse_single_val(text).lower() in ('false', 'true'),
},
### END: LEGACY SECTION TO BE REMOVED ###
},
'Service Discovery': {
'zeroconf': {
'convert': functools.partial(_to_bool, positive='enabled'),
'default': True,
'txt-chk': lambda text: _parse_single_val(text).lower() in ('disabled', 'enabled'),
},
},
'Discovery controller connection management': {
'persistent-connections': {
'convert': _to_bool,
'default': True,
'txt-chk': lambda text: _parse_single_val(text).lower() in ('false', 'true'),
},
'zeroconf-connections-persistence': {
'convert': lambda text: timeparse.timeparse(_parse_single_val(text)),
'default': timeparse.timeparse('72hours'),
},
},
'I/O controller connection management': {
'disconnect-scope': {
'convert': _parse_single_val,
'default': 'only-stas-connections',
'txt-chk': lambda text: _parse_single_val(text)
in ('only-stas-connections', 'all-connections-matching-disconnect-trtypes', 'no-disconnect'),
},
'disconnect-trtypes': {
# Use set() to eliminate potential duplicates
'convert': lambda text: set(_parse_single_val(text).split('+')),
'default': [
'tcp',
],
'lst-chk': ('tcp', 'rdma', 'fc'),
},
'connect-attempts-on-ncc': {
'convert': _to_ncc,
'default': 0,
},
},
'Controllers': {
'controller': {
'convert': _parse_list,
'default': [],
},
'exclude': {
'convert': _parse_list,
'default': [],
},
### BEGIN: LEGACY SECTION TO BE REMOVED ###
'blacklist': {
'convert': _parse_list,
'default': [],
},
### END: LEGACY SECTION TO BE REMOVED ###
},
}
def __init__(self, default_conf=None, conf_file='/dev/null'):
self._config = None
self._defaults = default_conf if default_conf else {}
if self._defaults is not None and len(self._defaults) != 0:
self._valid_conf = {}
for section, option in self._defaults:
self._valid_conf.setdefault(section, set()).add(option)
else:
self._valid_conf = None
self._conf_file = conf_file
self.reload()
def reload(self):
'''@brief Reload the configuration file.'''
self._config = self._read_conf_file()
@property
def conf_file(self):
'''Return the configuration file name'''
return self._conf_file
def set_conf_file(self, fname):
'''Set the configuration file name and reload config'''
self._conf_file = fname
self.reload()
def get_option(self, section, option, ignore_default=False): # pylint: disable=too-many-locals
'''Retrieve @option from @section, convert raw text to
appropriate object type, and validate.'''
try:
checker = self.OPTION_CHECKER[section][option]
except KeyError:
logging.error('Requesting invalid section=%s and/or option=%s', section, option)
raise
default = checker.get('default', None)
try:
text = self._config.get(section=section, option=option)
except (configparser.NoSectionError, configparser.NoOptionError, KeyError):
return None if ignore_default else self._defaults.get((section, option), default)
return self._check(text, section, option, default)
tron = property(functools.partial(get_option, section='Global', option='tron'))
kato = property(functools.partial(get_option, section='Global', option='kato'))
ip_family = property(functools.partial(get_option, section='Global', option='ip-family'))
queue_size = property(functools.partial(get_option, section='Global', option='queue-size'))
hdr_digest = property(functools.partial(get_option, section='Global', option='hdr-digest'))
data_digest = property(functools.partial(get_option, section='Global', option='data-digest'))
ignore_iface = property(functools.partial(get_option, section='Global', option='ignore-iface'))
pleo_enabled = property(functools.partial(get_option, section='Global', option='pleo'))
nr_io_queues = property(functools.partial(get_option, section='Global', option='nr-io-queues'))
ctrl_loss_tmo = property(functools.partial(get_option, section='Global', option='ctrl-loss-tmo'))
disable_sqflow = property(functools.partial(get_option, section='Global', option='disable-sqflow'))
nr_poll_queues = property(functools.partial(get_option, section='Global', option='nr-poll-queues'))
nr_write_queues = property(functools.partial(get_option, section='Global', option='nr-write-queues'))
reconnect_delay = property(functools.partial(get_option, section='Global', option='reconnect-delay'))
zeroconf_enabled = property(functools.partial(get_option, section='Service Discovery', option='zeroconf'))
zeroconf_persistence_sec = property(
functools.partial(
get_option, section='Discovery controller connection management', option='zeroconf-connections-persistence'
)
)
disconnect_scope = property(
functools.partial(get_option, section='I/O controller connection management', option='disconnect-scope')
)
disconnect_trtypes = property(
functools.partial(get_option, section='I/O controller connection management', option='disconnect-trtypes')
)
connect_attempts_on_ncc = property(
functools.partial(get_option, section='I/O controller connection management', option='connect-attempts-on-ncc')
)
@property
def stypes(self):
'''@brief Get the DNS-SD/mDNS service types.'''
return ['_nvme-disc._tcp', '_nvme-disc._udp'] if self.zeroconf_enabled else list()
@property
def persistent_connections(self):
'''@brief return the "persistent-connections" config parameter'''
section = 'Discovery controller connection management'
option = 'persistent-connections'
value = self.get_option(section, option, ignore_default=True)
legacy = self.get_option('Global', option, ignore_default=True)
if value is None and legacy is None:
return self._defaults.get((section, option), True)
return value or legacy
def get_controllers(self):
'''@brief Return the list of controllers in the config file.
Each controller is in the form of a dictionary as follows.
Note that some of the keys are optional.
{
'transport': [TRANSPORT],
'traddr': [TRADDR],
'trsvcid': [TRSVCID],
'subsysnqn': [NQN],
'host-traddr': [TRADDR],
'host-iface': [IFACE],
'host-nqn': [NQN],
'dhchap-ctrl-secret': [KEY],
'hdr-digest': [BOOL]
'data-digest': [BOOL]
'nr-io-queues': [NUMBER]
'nr-write-queues': [NUMBER]
'nr-poll-queues': [NUMBER]
'queue-size': [SIZE]
'kato': [KATO]
'reconnect-delay': [SECONDS]
'ctrl-loss-tmo': [SECONDS]
'disable-sqflow': [BOOL]
}
'''
controller_list = self.get_option('Controllers', 'controller')
cids = [_parse_controller(controller) for controller in controller_list]
for cid in cids:
try:
# replace 'nqn' key by 'subsysnqn', if present.
cid['subsysnqn'] = cid.pop('nqn')
except KeyError:
pass
# Verify values of the options used to overload the matching [Global] options
for option in cid:
if option in self.OPTION_CHECKER['Global']:
value = self._check(cid[option], 'Global', option, None)
if value is not None:
cid[option] = value
return cids
def get_excluded(self):
'''@brief Return the list of excluded controllers in the config file.
Each excluded controller is in the form of a dictionary
as follows. All the keys are optional.
{
'transport': [TRANSPORT],
'traddr': [TRADDR],
'trsvcid': [TRSVCID],
'host-iface': [IFACE],
'subsysnqn': [NQN],
}
'''
controller_list = self.get_option('Controllers', 'exclude')
# 2022-09-20: Look for "blacklist". This is for backwards compatibility
# with releases 1.0 to 1.1.x. This is to be phased out (i.e. remove by 2024)
controller_list += self.get_option('Controllers', 'blacklist')
excluded = [_parse_controller(controller) for controller in controller_list]
for controller in excluded:
controller.pop('host-traddr', None) # remove host-traddr
try:
# replace 'nqn' key by 'subsysnqn', if present.
controller['subsysnqn'] = controller.pop('nqn')
except KeyError:
pass
return excluded
def _check(self, text, section, option, default):
checker = self.OPTION_CHECKER[section][option]
text_checker = checker.get('txt-chk', None)
if text_checker is not None and not text_checker(text):
logging.warning(
'File:%s [%s]: %s - Text check found invalid value "%s". Default will be used',
self.conf_file,
section,
option,
text,
)
return self._defaults.get((section, option), default)
converter = checker.get('convert', None)
try:
value = converter(text)
except InvalidOption:
logging.warning(
'File:%s [%s]: %s - Data converter found invalid value "%s". Default will be used',
self.conf_file,
section,
option,
text,
)
return self._defaults.get((section, option), default)
value_in_range = checker.get('rng-chk', None)
if value_in_range is not None:
expected_range = value_in_range(value)
if expected_range is not None:
logging.warning(
'File:%s [%s]: %s - "%s" is not within range %s..%s. Default will be used',
self.conf_file,
section,
option,
value,
min(expected_range),
max(expected_range),
)
return self._defaults.get((section, option), default)
list_checker = checker.get('lst-chk', None)
if list_checker:
values = set()
for item in value:
if item not in list_checker:
logging.warning(
'File:%s [%s]: %s - List checker found invalid item "%s" will be ignored.',
self.conf_file,
section,
option,
item,
)
else:
values.add(item)
if len(values) == 0:
return self._defaults.get((section, option), default)
value = list(values)
return value
def _read_conf_file(self):
'''@brief Read the configuration file if the file exists.'''
config = configparser.ConfigParser(
default_section=None,
allow_no_value=True,
delimiters=('='),
interpolation=None,
strict=False,
dict_type=OrderedMultisetDict,
)
if self._conf_file and os.path.isfile(self._conf_file):
config.read(self._conf_file)
# Parse Configuration and validate.
if self._valid_conf is not None:
invalid_sections = set()
for section in config.sections():
if section not in self._valid_conf:
invalid_sections.add(section)
else:
invalid_options = set()
for option in config.options(section):
if option not in self._valid_conf.get(section, []):
invalid_options.add(option)
if len(invalid_options) != 0:
logging.error(
'File:%s [%s] contains invalid options: %s',
self.conf_file,
section,
invalid_options,
)
if len(invalid_sections) != 0:
logging.error(
'File:%s contains invalid sections: %s',
self.conf_file,
invalid_sections,
)
return config
# ******************************************************************************
class SysConf(metaclass=singleton.Singleton):
'''Read and cache the host configuration file.'''
def __init__(self, conf_file=defs.SYS_CONF_FILE):
self._config = None
self._conf_file = conf_file
self.reload()
def reload(self):
'''@brief Reload the configuration file.'''
self._config = self._read_conf_file()
@property
def conf_file(self):
'''Return the configuration file name'''
return self._conf_file
def set_conf_file(self, fname):
'''Set the configuration file name and reload config'''
self._conf_file = fname
self.reload()
def as_dict(self):
'''Return configuration as a dictionary'''
return {
'hostnqn': self.hostnqn,
'hostid': self.hostid,
'hostkey': self.hostkey,
'symname': self.hostsymname,
}
@property
def hostnqn(self):
'''@brief return the host NQN
@return: Host NQN
@raise: Host NQN is mandatory. The program will terminate if a
Host NQN cannot be determined.
'''
try:
value = self.__get_value('Host', 'nqn', defs.NVME_HOSTNQN)
except FileNotFoundError as ex:
sys.exit(f'Error reading mandatory Host NQN (see stasadm --help): {ex}')
if value is not None and not value.startswith('nqn.'):
sys.exit(f'Error Host NQN "{value}" should start with "nqn."')
return value
@property
def hostid(self):
'''@brief return the host ID
@return: Host ID
@raise: Host ID is mandatory. The program will terminate if a
Host ID cannot be determined.
'''
try:
value = self.__get_value('Host', 'id', defs.NVME_HOSTID)
except FileNotFoundError as ex:
sys.exit(f'Error reading mandatory Host ID (see stasadm --help): {ex}')
return value
@property
def hostkey(self):
'''@brief return the host key
@return: Host key
@raise: Host key is optional, but mandatory if authorization will be performed.
'''
try:
value = self.__get_value('Host', 'key', defs.NVME_HOSTKEY)
except FileNotFoundError as ex:
logging.debug('Host key undefined: %s', ex)
value = None
return value
@property
def hostsymname(self):
'''@brief return the host symbolic name (or None)
@return: symbolic name or None
'''
try:
value = self.__get_value('Host', 'symname')
except FileNotFoundError as ex:
logging.warning('Error reading host symbolic name (will remain undefined): %s', ex)
value = None
return value
def _read_conf_file(self):
'''@brief Read the configuration file if the file exists.'''
config = configparser.ConfigParser(
default_section=None, allow_no_value=True, delimiters=('='), interpolation=None, strict=False
)
if os.path.isfile(self._conf_file):
config.read(self._conf_file)
return config
def __get_value(self, section, option, default_file=None):
'''@brief A configuration file consists of sections, each led by a
[section] header, followed by key/value entries separated
by a equal sign (=). This method retrieves the value
associated with the key @option from the section @section.
If the value starts with the string "file://", then the value
will be retrieved from that file.
@param section: Configuration section
@param option: The key to look for
@param default_file: A file that contains the default value
@return: On success, the value associated with the key. On failure,
this method will return None is a default_file is not
specified, or will raise an exception if a file is not
found.
@raise: This method will raise the FileNotFoundError exception if
the value retrieved is a file that does not exist.
'''
try:
value = self._config.get(section=section, option=option)
if not value.startswith('file://'):
return value
file = value[7:]
except (configparser.NoSectionError, configparser.NoOptionError, KeyError):
if default_file is None:
return None
file = default_file
try:
with open(file) as f: # pylint: disable=unspecified-encoding
return f.readline().split()[0]
except IndexError:
return None
# ******************************************************************************
class NvmeOptions(metaclass=singleton.Singleton):
'''Object used to read and cache contents of file /dev/nvme-fabrics.
Note that this file was not readable prior to Linux 5.16.
'''
def __init__(self):
# Supported options can be determined by looking at the kernel version
# or by reading '/dev/nvme-fabrics'. The ability to read the options
# from '/dev/nvme-fabrics' was only introduced in kernel 5.17, but may
# have been backported to older kernels. In any case, if the kernel
# version meets the minimum version for that option, then we don't
# even need to read '/dev/nvme-fabrics'.
self._supported_options = {
'discovery': defs.KERNEL_VERSION >= defs.KERNEL_TP8013_MIN_VERSION,
'host_iface': defs.KERNEL_VERSION >= defs.KERNEL_IFACE_MIN_VERSION,
'dhchap_secret': defs.KERNEL_VERSION >= defs.KERNEL_HOSTKEY_MIN_VERSION,
'dhchap_ctrl_secret': defs.KERNEL_VERSION >= defs.KERNEL_CTRLKEY_MIN_VERSION,
}
# If some of the options are False, we need to check whether they can be
# read from '/dev/nvme-fabrics'. This method allows us to determine that
# an older kernel actually supports a specific option because it was
# backported to that kernel.
if not all(self._supported_options.values()): # At least one option is False.
try:
with open('/dev/nvme-fabrics') as f: # pylint: disable=unspecified-encoding
options = [option.split('=')[0].strip() for option in f.readline().rstrip('\n').split(',')]
except PermissionError: # Must be root to read this file
raise
except (OSError, FileNotFoundError):
logging.warning('Cannot determine which NVMe options the kernel supports')
else:
for option, supported in self._supported_options.items():
if not supported:
self._supported_options[option] = option in options
def __str__(self):
return f'supported options: {self._supported_options}'
def get(self):
'''get the supported options as a dict'''
return self._supported_options
@property
def discovery_supp(self):
'''This option adds support for TP8013'''
return self._supported_options['discovery']
@property
def host_iface_supp(self):
'''This option allows forcing connections to go over
a specific interface regardless of the routing tables.
'''
return self._supported_options['host_iface']
@property
def dhchap_hostkey_supp(self):
'''This option allows specifying the host DHCHAP key used for authentication.'''
return self._supported_options['dhchap_secret']
@property
def dhchap_ctrlkey_supp(self):
'''This option allows specifying the controller DHCHAP key used for authentication.'''
return self._supported_options['dhchap_ctrl_secret']
# ******************************************************************************
class NbftConf(metaclass=singleton.Singleton):
'''Read and cache configuration file.'''
def __init__(self, root_dir=defs.NBFT_SYSFS_PATH):
self._disc_ctrls = []
self._subs_ctrls = []
nbft_files = nbft.get_nbft_files(root_dir)
if len(nbft_files):
logging.info('NBFT location(s): %s', list(nbft_files.keys()))
for data in nbft_files.values():
hfis = data.get('hfi', [])
discovery = data.get('discovery', [])
subsystem = data.get('subsystem', [])
host = data.get('host', {})
hostnqn = host.get('nqn', None) if host.get('host_nqn_configured', False) else None
self._disc_ctrls.extend(NbftConf.__nbft_disc_to_cids(hostnqn, discovery, hfis))
self._subs_ctrls.extend(NbftConf.__nbft_subs_to_cids(hostnqn, subsystem, hfis))
dcs = property(lambda self: self._disc_ctrls)
iocs = property(lambda self: self._subs_ctrls)
def get_controllers(self):
'''Retrieve the list of controllers. Stafd only cares about
discovery controllers. Stacd only cares about I/O controllers.'''
# For now, only return DCs. There are still unanswered questions
# regarding I/O controllers, e.g. what if multipathing has been
# configured.
return self.dcs if defs.PROG_NAME == 'stafd' else []
@staticmethod
def __nbft_disc_to_cids(hostnqn, discovery, hfis):
cids = []
for ctrl in discovery:
cid = NbftConf.__uri2cid(ctrl['uri'])
cid['subsysnqn'] = ctrl['nqn']
if hostnqn:
cid['host-nqn'] = hostnqn
host_iface = NbftConf.__get_host_iface(ctrl.get('hfi_index'), hfis)
if host_iface:
cid['host-iface'] = host_iface
cids.append(cid)
return cids
@staticmethod
def __nbft_subs_to_cids(hostnqn, subsystem, hfis):
cids = []
for ctrl in subsystem:
cid = {
'transport': ctrl['trtype'],
'traddr': ctrl['traddr'],
'trsvcid': ctrl['trsvcid'],
'subsysnqn': ctrl['subsys_nqn'],
'hdr-digest': ctrl['pdu_header_digest_required'],
'data-digest': ctrl['data_digest_required'],
}
if hostnqn:
cid['host-nqn'] = hostnqn
indexes = ctrl.get('hfi_indexes')
if isinstance(indexes, list) and len(indexes) > 0:
host_iface = NbftConf.__get_host_iface(indexes[0], hfis)
if host_iface:
cid['host-iface'] = host_iface
cids.append(cid)
return cids
@staticmethod
def __get_host_iface(indx, hfis):
if indx is None or indx >= len(hfis):
return None
mac = hfis[indx].get('mac_addr')
if mac is None:
return None
return iputil.mac2iface(mac)
@staticmethod
def __uri2cid(uri: str):
'''Convert a URI of the form "nvme+tcp://100.71.103.50:8009/" to a Controller ID'''
obj = urlparse(uri)
return {
'transport': obj.scheme.split('+')[1],
'traddr': obj.hostname,
'trsvcid': str(obj.port),
}