1
0
Fork 0

Adding upstream version 2.2.1.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Benjamin Drung 2025-02-16 12:41:59 +01:00 committed by Daniel Baumann
parent 1d36de0179
commit 757b718eff
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
129 changed files with 16110 additions and 0 deletions

1
staslib/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
__pycache__

11
staslib/__init__.py Normal file
View file

@ -0,0 +1,11 @@
# 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>
#
'''STorage Appliance Services'''
__version__ = '@VERSION@'

456
staslib/avahi.py Normal file
View file

@ -0,0 +1,456 @@
# Copyright (c) 2021, 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>
#
''' Module that provides a way to retrieve discovered
services from the Avahi daemon over D-Bus.
'''
import socket
import typing
import logging
import functools
import dasbus.error
import dasbus.connection
import dasbus.client.proxy
import dasbus.client.observer
from gi.repository import GLib
from staslib import defs, conf, gutil
def _txt2dict(txt: list):
'''@param txt: A list of list of integers. The integers are the ASCII value
of printable text characters.
'''
the_dict = dict()
for list_of_chars in txt:
try:
string = functools.reduce(lambda accumulator, c: accumulator + chr(c), list_of_chars, '')
key, val = string.split("=")
the_dict[key.lower()] = val
except Exception: # pylint: disable=broad-except
pass
return the_dict
def _proto2trans(protocol):
'''Return the matching transport for the given protocol.'''
if protocol is None:
return None
protocol = protocol.strip().lower()
if protocol == 'tcp':
return 'tcp'
if protocol in ('roce', 'iwarp', 'rdma'):
return 'rdma'
return None
# ******************************************************************************
class Avahi: # pylint: disable=too-many-instance-attributes
'''@brief Avahi Server proxy. Set up the D-Bus connection to the Avahi
daemon and register to be notified when services of a certain
type (stype) are discovered or lost.
'''
DBUS_NAME = 'org.freedesktop.Avahi'
DBUS_INTERFACE_SERVICE_BROWSER = DBUS_NAME + '.ServiceBrowser'
DBUS_INTERFACE_SERVICE_RESOLVER = DBUS_NAME + '.ServiceResolver'
LOOKUP_USE_MULTICAST = 2
IF_UNSPEC = -1
PROTO_INET = 0
PROTO_INET6 = 1
PROTO_UNSPEC = -1
LOOKUP_RESULT_LOCAL = 8 # This record/service resides on and was announced by the local host
LOOKUP_RESULT_CACHED = 1 # This response originates from the cache
LOOKUP_RESULT_STATIC = 32 # The returned data has been defined statically by some configuration option
LOOKUP_RESULT_OUR_OWN = 16 # This service belongs to the same local client as the browser object
LOOKUP_RESULT_WIDE_AREA = 2 # This response originates from wide area DNS
LOOKUP_RESULT_MULTICAST = 4 # This response originates from multicast DNS
result_flags = {
LOOKUP_RESULT_LOCAL: 'local',
LOOKUP_RESULT_CACHED: 'cache',
LOOKUP_RESULT_STATIC: 'static',
LOOKUP_RESULT_OUR_OWN: 'own',
LOOKUP_RESULT_WIDE_AREA: 'wan',
LOOKUP_RESULT_MULTICAST: 'mcast',
}
protos = {PROTO_INET: 'IPv4', PROTO_INET6: 'IPv6', PROTO_UNSPEC: 'uspecified'}
@classmethod
def result_flags_as_string(cls, flags):
'''Convert flags to human-readable string'''
return '+'.join((value for flag, value in Avahi.result_flags.items() if (flags & flag) != 0))
@classmethod
def protocol_as_string(cls, proto):
'''Convert protocol codes to human-readable strings'''
return Avahi.protos.get(proto, 'unknown')
# ==========================================================================
def __init__(self, sysbus, change_cb):
self._change_cb = change_cb
self._services = dict()
self._sysbus = sysbus
self._stypes = set()
self._service_browsers = dict()
# Avahi is an on-demand service. If, for some reason, the avahi-daemon
# were to stop, we need to try to contact it for it to restart. For
# example, when installing the avahi-daemon package on a running system,
# the daemon doesn't get started right away. It needs another process to
# access it over D-Bus to wake it up. The following timer is used to
# periodically query the avahi-daemon until we successfully establish
# first contact.
self._kick_avahi_tmr = gutil.GTimer(60, self._on_kick_avahi)
# Subscribe for Avahi signals (i.e. events). This must be done before
# any Browser or Resolver is created to avoid race conditions and
# missed events.
self._subscriptions = [
self._sysbus.connection.signal_subscribe(
Avahi.DBUS_NAME,
Avahi.DBUS_INTERFACE_SERVICE_BROWSER,
'ItemNew',
None,
None,
0,
self._service_discovered,
),
self._sysbus.connection.signal_subscribe(
Avahi.DBUS_NAME,
Avahi.DBUS_INTERFACE_SERVICE_BROWSER,
'ItemRemove',
None,
None,
0,
self._service_removed,
),
self._sysbus.connection.signal_subscribe(
Avahi.DBUS_NAME, Avahi.DBUS_INTERFACE_SERVICE_BROWSER, 'Failure', None, None, 0, self._failure_handler
),
self._sysbus.connection.signal_subscribe(
Avahi.DBUS_NAME, Avahi.DBUS_INTERFACE_SERVICE_RESOLVER, 'Found', None, None, 0, self._service_identified
),
self._sysbus.connection.signal_subscribe(
Avahi.DBUS_NAME, Avahi.DBUS_INTERFACE_SERVICE_RESOLVER, 'Failure', None, None, 0, self._failure_handler
),
]
self._avahi = self._sysbus.get_proxy(Avahi.DBUS_NAME, '/')
self._avahi_watcher = dasbus.client.observer.DBusObserver(self._sysbus, Avahi.DBUS_NAME)
self._avahi_watcher.service_available.connect(self._avahi_available)
self._avahi_watcher.service_unavailable.connect(self._avahi_unavailable)
self._avahi_watcher.connect_once_available()
def kill(self):
'''@brief Clean up object'''
logging.debug('Avahi.kill()')
self._kick_avahi_tmr.kill()
self._kick_avahi_tmr = None
for subscription in self._subscriptions:
self._sysbus.connection.signal_unsubscribe(subscription)
self._subscriptions = list()
self._disconnect()
self._avahi_watcher.service_available.disconnect()
self._avahi_watcher.service_unavailable.disconnect()
self._avahi_watcher.disconnect()
self._avahi_watcher = None
dasbus.client.proxy.disconnect_proxy(self._avahi)
self._avahi = None
self._change_cb = None
self._sysbus = None
def info(self) -> dict:
'''@brief return debug info about this object'''
services = dict()
for service, obj in self._services.items():
interface, protocol, name, stype, domain = service
key = f'({socket.if_indextoname(interface)}, {Avahi.protos.get(protocol, "unknown")}, {name}.{domain}, {stype})'
services[key] = obj.get('data', {})
info = {
'avahi wake up timer': str(self._kick_avahi_tmr),
'service types': list(self._stypes),
'services': services,
}
return info
def get_controllers(self) -> list:
'''@brief Get the discovery controllers as a list of dict()
as follows:
[
{
'transport': tcp,
'traddr': str(),
'trsvcid': str(),
'host-iface': str(),
'subsysnqn': 'nqn.2014-08.org.nvmexpress.discovery',
},
{
'transport': tcp,
'traddr': str(),
'trsvcid': str(),
'host-iface': str(),
'subsysnqn': 'nqn.2014-08.org.nvmexpress.discovery',
},
[...]
]
'''
return [service['data'] for service in self._services.values() if len(service['data'])]
def config_stypes(self, stypes: list):
'''@brief Configure the service types that we want to discover.
@param stypes: A list of services types, e.g. ['_nvme-disc._tcp']
'''
self._stypes = set(stypes)
success = self._configure_browsers()
if not success:
self._kick_avahi_tmr.start()
def kick_start(self):
'''@brief We use this to kick start the Avahi
daemon (i.e. socket activation).
'''
self._kick_avahi_tmr.clear()
def _disconnect(self):
logging.debug('Avahi._disconnect()')
for service in self._services.values():
resolver = service.pop('resolver', None)
if resolver is not None:
try:
resolver.Free()
dasbus.client.proxy.disconnect_proxy(resolver)
except (AttributeError, dasbus.error.DBusError) as ex:
logging.debug('Avahi._disconnect() - Failed to Free() resolver. %s', ex)
self._services = dict()
for browser in self._service_browsers.values():
try:
browser.Free()
dasbus.client.proxy.disconnect_proxy(browser)
except (AttributeError, dasbus.error.DBusError) as ex:
logging.debug('Avahi._disconnect() - Failed to Free() browser. %s', ex)
self._service_browsers = dict()
def _on_kick_avahi(self):
try:
# try to contact avahi-daemon. This is just a wake
# up call in case the avahi-daemon was sleeping.
self._avahi.GetVersionString()
except dasbus.error.DBusError:
return GLib.SOURCE_CONTINUE
return GLib.SOURCE_REMOVE
def _avahi_available(self, _avahi_watcher):
'''@brief Hook up DBus signal handlers for signals from stafd.'''
logging.info('avahi-daemon service available, zeroconf supported.')
success = self._configure_browsers()
if not success:
self._kick_avahi_tmr.start()
def _avahi_unavailable(self, _avahi_watcher):
self._disconnect()
logging.warning('avahi-daemon not available, zeroconf not supported.')
self._kick_avahi_tmr.start()
def _configure_browsers(self):
stypes_cur = set(self._service_browsers.keys())
stypes_to_add = self._stypes - stypes_cur
stypes_to_rm = stypes_cur - self._stypes
logging.debug('Avahi._configure_browsers() - stypes_to_rm = %s', list(stypes_to_rm))
logging.debug('Avahi._configure_browsers() - stypes_to_add = %s', list(stypes_to_add))
for stype_to_rm in stypes_to_rm:
browser = self._service_browsers.pop(stype_to_rm, None)
if browser is not None:
try:
browser.Free()
dasbus.client.proxy.disconnect_proxy(browser)
except (AttributeError, dasbus.error.DBusError) as ex:
logging.debug('Avahi._configure_browsers() - Failed to Free() browser. %s', ex)
# Find the cached services corresponding to stype_to_rm and remove them
services_to_rm = [service for service in self._services if service[3] == stype_to_rm]
for service in services_to_rm:
resolver = self._services.pop(service, {}).pop('resolver', None)
if resolver is not None:
try:
resolver.Free()
dasbus.client.proxy.disconnect_proxy(resolver)
except (AttributeError, dasbus.error.DBusError) as ex:
logging.debug('Avahi._configure_browsers() - Failed to Free() resolver. %s', ex)
for stype in stypes_to_add:
try:
obj_path = self._avahi.ServiceBrowserNew(
Avahi.IF_UNSPEC, Avahi.PROTO_UNSPEC, stype, 'local', Avahi.LOOKUP_USE_MULTICAST
)
self._service_browsers[stype] = self._sysbus.get_proxy(Avahi.DBUS_NAME, obj_path)
except dasbus.error.DBusError as ex:
logging.debug('Avahi._configure_browsers() - Failed to contact avahi-daemon. %s', ex)
logging.warning('avahi-daemon not available, operating w/o mDNS discovery.')
return False
return True
def _service_discovered(
self,
_connection,
_sender_name: str,
_object_path: str,
_interface_name: str,
_signal_name: str,
args: typing.Tuple[int, int, str, str, str, int],
*_user_data,
):
(interface, protocol, name, stype, domain, flags) = args
logging.debug(
'Avahi._service_discovered() - interface=%s (%s), protocol=%s, stype=%s, domain=%s, flags=%s %-14s name=%s',
interface,
socket.if_indextoname(interface),
Avahi.protocol_as_string(protocol),
stype,
domain,
flags,
'(' + Avahi.result_flags_as_string(flags) + '),',
name,
)
service = (interface, protocol, name, stype, domain)
if service not in self._services:
try:
obj_path = self._avahi.ServiceResolverNew(
interface, protocol, name, stype, domain, Avahi.PROTO_UNSPEC, Avahi.LOOKUP_USE_MULTICAST
)
self._services[service] = {
'resolver': self._sysbus.get_proxy(Avahi.DBUS_NAME, obj_path),
'data': {},
}
except dasbus.error.DBusError as ex:
logging.warning('Failed to create resolver: "%s", "%s", "%s". %s', interface, name, stype, ex)
def _service_removed(
self,
_connection,
_sender_name: str,
_object_path: str,
_interface_name: str,
_signal_name: str,
args: typing.Tuple[int, int, str, str, str, int],
*_user_data,
):
(interface, protocol, name, stype, domain, flags) = args
logging.debug(
'Avahi._service_removed() - interface=%s (%s), protocol=%s, stype=%s, domain=%s, flags=%s %-14s name=%s',
interface,
socket.if_indextoname(interface),
Avahi.protocol_as_string(protocol),
stype,
domain,
flags,
'(' + Avahi.result_flags_as_string(flags) + '),',
name,
)
service = (interface, protocol, name, stype, domain)
resolver = self._services.pop(service, {}).pop('resolver', None)
if resolver is not None:
try:
resolver.Free()
dasbus.client.proxy.disconnect_proxy(resolver)
except (AttributeError, dasbus.error.DBusError) as ex:
logging.debug('Avahi._service_removed() - Failed to Free() resolver. %s', ex)
self._change_cb()
def _service_identified( # pylint: disable=too-many-locals
self,
_connection,
_sender_name: str,
_object_path: str,
_interface_name: str,
_signal_name: str,
args: typing.Tuple[int, int, str, str, str, str, int, str, int, list, int],
*_user_data,
):
(interface, protocol, name, stype, domain, host, aprotocol, address, port, txt, flags) = args
txt = _txt2dict(txt)
logging.debug(
'Avahi._service_identified() - interface=%s (%s), protocol=%s, stype=%s, domain=%s, flags=%s %-14s name=%s, host=%s, aprotocol=%s, address=%s, port=%s, txt=%s',
interface,
socket.if_indextoname(interface),
Avahi.protocol_as_string(protocol),
stype,
domain,
flags,
'(' + Avahi.result_flags_as_string(flags) + '),',
name,
host,
Avahi.protocol_as_string(aprotocol),
address,
port,
txt,
)
service = (interface, protocol, name, stype, domain)
if service in self._services:
transport = _proto2trans(txt.get('p'))
if transport is not None:
self._services[service]['data'] = {
'transport': transport,
'traddr': address.strip(),
'trsvcid': str(port).strip(),
# host-iface permitted for tcp alone and not rdma
'host-iface': socket.if_indextoname(interface).strip() if transport == 'tcp' else '',
'subsysnqn': txt.get('nqn', defs.WELL_KNOWN_DISC_NQN).strip()
if conf.NvmeOptions().discovery_supp
else defs.WELL_KNOWN_DISC_NQN,
}
self._change_cb()
else:
logging.error(
'Received invalid/undefined protocol in mDNS TXT field: address=%s, iface=%s, TXT=%s',
address,
socket.if_indextoname(interface).strip(),
txt,
)
def _failure_handler( # pylint: disable=no-self-use
self,
_connection,
_sender_name: str,
_object_path: str,
interface_name: str,
_signal_name: str,
args: typing.Tuple[str],
*_user_data,
):
(error,) = args
if 'ServiceResolver' not in interface_name or 'TimeoutError' not in error:
# ServiceResolver may fire a timeout event after being Free'd(). This seems to be normal.
logging.error('Avahi._failure_handler() - name=%s, error=%s', interface_name, error)

703
staslib/conf.py Normal file
View file

@ -0,0 +1,703 @@
# 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 staslib import defs, 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.
'''
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', 'persistent-connections', 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],
'host-traddr': [TRADDR],
'host-iface': [IFACE],
'subsysnqn': [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.6. 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.info('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 wether 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']

850
staslib/ctrl.py Normal file
View file

@ -0,0 +1,850 @@
# 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>
#
'''This module defines the base Controller object from which the
Dc (Discovery Controller) and Ioc (I/O Controller) objects are derived.'''
import time
import inspect
import logging
from gi.repository import GLib
from libnvme import nvme
from staslib import conf, defs, gutil, trid, udev, stas
DLP_CHANGED = (
(nvme.NVME_LOG_LID_DISCOVER << 16) | (nvme.NVME_AER_NOTICE_DISC_CHANGED << 8) | nvme.NVME_AER_NOTICE
) # 0x70f002
def get_eflags(dlpe):
'''@brief Return eflags field of dlpe'''
return int(dlpe.get('eflags', 0)) if dlpe else 0
def get_ncc(eflags: int):
'''@brief Return True if Not Connected to CDC bit is asserted, False otherwise'''
return eflags & nvme.NVMF_DISC_EFLAGS_NCC != 0
def dlp_supp_opts_as_string(dlp_supp_opts: int):
'''@brief Return the list of options supported by the Get
discovery log page command.
'''
data = {
nvme.NVMF_LOG_DISC_LID_EXTDLPES: "EXTDLPES",
nvme.NVMF_LOG_DISC_LID_PLEOS: "PLEOS",
nvme.NVMF_LOG_DISC_LID_ALLSUBES: "ALLSUBES",
}
return [txt for msk, txt in data.items() if dlp_supp_opts & msk]
# ******************************************************************************
class Controller(stas.ControllerABC): # pylint: disable=too-many-instance-attributes
'''@brief Base class used to manage the connection to a controller.'''
def __init__(self, tid: trid.TID, service, discovery_ctrl: bool = False):
sysconf = conf.SysConf()
self._nvme_options = conf.NvmeOptions()
self._root = nvme.root()
self._host = nvme.host(
self._root, hostnqn=sysconf.hostnqn, hostid=sysconf.hostid, hostsymname=sysconf.hostsymname
)
self._host.dhchap_key = sysconf.hostkey if self._nvme_options.dhchap_hostkey_supp else None
self._udev = udev.UDEV
self._device = None # Refers to the nvme device (e.g. /dev/nvme[n])
self._ctrl = None # libnvme's nvme.ctrl object
self._connect_op = None
super().__init__(tid, service, discovery_ctrl)
def _release_resources(self):
logging.debug('Controller._release_resources() - %s | %s', self.id, self.device)
if self._udev:
self._udev.unregister_for_device_events(self._on_udev_notification)
self._kill_ops()
super()._release_resources()
self._ctrl = None
self._udev = None
self._host = None
self._root = None
self._nvme_options = None
@property
def device(self) -> str:
'''@brief return the Linux nvme device id (e.g. nvme3) or empty
string if no device is associated with this controller'''
if not self._device and self._ctrl and self._ctrl.name:
self._device = self._ctrl.name
return self._device or 'nvme?'
def all_ops_completed(self) -> bool:
'''@brief Returns True if all operations have completed. False otherwise.'''
return self._connect_op is None or self._connect_op.completed()
def connected(self):
'''@brief Return whether a connection is established'''
return self._ctrl and self._ctrl.connected()
def controller_id_dict(self) -> dict:
'''@brief return the controller ID as a dict.'''
cid = super().controller_id_dict()
cid['device'] = self.device
return cid
def details(self) -> dict:
'''@brief return detailed debug info about this controller'''
details = super().details()
details.update(
self._udev.get_attributes(self.device, ('hostid', 'hostnqn', 'model', 'serial', 'dctype', 'cntrltype'))
)
details['connected'] = str(self.connected())
return details
def info(self) -> dict:
'''@brief Get the controller info for this object'''
info = super().info()
if self._connect_op:
info['connect operation'] = str(self._connect_op.as_dict())
return info
def cancel(self):
'''@brief Used to cancel pending operations.'''
super().cancel()
if self._connect_op:
self._connect_op.cancel()
def _kill_ops(self):
if self._connect_op:
self._connect_op.kill()
self._connect_op = None
def set_level_from_tron(self, tron):
'''Set log level based on TRON'''
if self._root:
self._root.log_level("debug" if tron else "err")
def _on_udev_notification(self, udev_obj):
if self._alive():
if udev_obj.action == 'change':
nvme_aen = udev_obj.get('NVME_AEN')
nvme_event = udev_obj.get('NVME_EVENT')
if isinstance(nvme_aen, str):
logging.info('%s | %s - Received AEN: %s', self.id, udev_obj.sys_name, nvme_aen)
self._on_aen(int(nvme_aen, 16))
if isinstance(nvme_event, str):
self._on_nvme_event(nvme_event)
elif udev_obj.action == 'remove':
logging.info('%s | %s - Received "remove" event', self.id, udev_obj.sys_name)
self._on_ctrl_removed(udev_obj)
else:
logging.debug(
'Controller._on_udev_notification() - %s | %s: Received "%s" event',
self.id,
udev_obj.sys_name,
udev_obj.action,
)
else:
logging.debug(
'Controller._on_udev_notification() - %s | %s: Received event on dead object. udev_obj %s: %s',
self.id,
self.device,
udev_obj.action,
udev_obj.sys_name,
)
def _on_ctrl_removed(self, udev_obj): # pylint: disable=unused-argument
if self._udev:
self._udev.unregister_for_device_events(self._on_udev_notification)
self._kill_ops() # Kill all pending operations
self._ctrl = None
# Defer removal of this object to the next main loop's idle period.
GLib.idle_add(self._serv.remove_controller, self, True)
def _get_cfg(self):
'''Get configuration parameters. These may either come from the [Global]
section or from a "controller" entry in the configuration file. A
definition found in a "controller" entry overrides the same definition
found in the [Global] section.
'''
cfg = {}
service_conf = conf.SvcConf()
for option, keyword in (
('kato', 'keep_alive_tmo'),
('queue-size', 'queue_size'),
('hdr-digest', 'hdr_digest'),
('data-digest', 'data_digest'),
('nr-io-queues', 'nr_io_queues'),
('ctrl-loss-tmo', 'ctrl_loss_tmo'),
('disable-sqflow', 'disable_sqflow'),
('nr-poll-queues', 'nr_poll_queues'),
('nr-write-queues', 'nr_write_queues'),
('reconnect-delay', 'reconnect_delay'),
):
# Check if the value is defined as a "controller" entry (i.e. override)
ovrd_val = self.tid.cfg.get(option, None)
if ovrd_val is not None:
cfg[keyword] = ovrd_val
else:
# Check if the value is found in the [Global] section.
glob_val = service_conf.get_option('Global', option)
if glob_val is not None:
cfg[keyword] = glob_val
return cfg
def _do_connect(self):
service_conf = conf.SvcConf()
host_iface = (
self.tid.host_iface
if (self.tid.host_iface and not service_conf.ignore_iface and self._nvme_options.host_iface_supp)
else None
)
self._ctrl = nvme.ctrl(
self._root,
subsysnqn=self.tid.subsysnqn,
transport=self.tid.transport,
traddr=self.tid.traddr,
trsvcid=self.tid.trsvcid if self.tid.trsvcid else None,
host_traddr=self.tid.host_traddr if self.tid.host_traddr else None,
host_iface=host_iface,
)
self._ctrl.discovery_ctrl_set(self._discovery_ctrl)
# Set the DHCHAP key on the controller
# NOTE that this will eventually have to
# change once we have support for AVE (TP8019)
ctrl_dhchap_key = self.tid.cfg.get('dhchap-ctrl-secret')
if ctrl_dhchap_key and self._nvme_options.dhchap_ctrlkey_supp:
has_dhchap_key = hasattr(self._ctrl, 'dhchap_key')
if not has_dhchap_key:
logging.warning(
'%s | %s - libnvme-%s does not allow setting the controller DHCHAP key. Please upgrade libnvme.',
self.id,
self.device,
defs.LIBNVME_VERSION,
)
else:
self._ctrl.dhchap_key = ctrl_dhchap_key
# Audit existing nvme devices. If we find a match, then
# we'll just borrow that device instead of creating a new one.
udev_obj = self._find_existing_connection()
if udev_obj is not None:
# A device already exists.
self._device = udev_obj.sys_name
logging.debug(
'Controller._do_connect() - %s Found existing control device: %s', self.id, udev_obj.sys_name
)
self._connect_op = gutil.AsyncTask(
self._on_connect_success, self._on_connect_fail, self._ctrl.init, self._host, int(udev_obj.sys_number)
)
else:
cfg = self._get_cfg()
logging.debug(
'Controller._do_connect() - %s Connecting to nvme control with cfg=%s', self.id, cfg
)
self._connect_op = gutil.AsyncTask(
self._on_connect_success, self._on_connect_fail, self._ctrl.connect, self._host, cfg
)
self._connect_op.run_async()
# --------------------------------------------------------------------------
def _on_connect_success(self, op_obj: gutil.AsyncTask, data):
'''@brief Function called when we successfully connect to the
Controller.
'''
op_obj.kill()
self._connect_op = None
if self._alive():
self._device = self._ctrl.name
logging.info('%s | %s - Connection established!', self.id, self.device)
self._connect_attempts = 0
self._udev.register_for_device_events(self._device, self._on_udev_notification)
else:
logging.debug(
'Controller._on_connect_success() - %s | %s: Received event on dead object. data=%s',
self.id,
self.device,
data,
)
def _on_connect_fail(self, op_obj: gutil.AsyncTask, err, fail_cnt): # pylint: disable=unused-argument
'''@brief Function called when we fail to connect to the Controller.'''
op_obj.kill()
self._connect_op = None
if self._alive():
if self._connect_attempts == 1:
# Do a fast re-try on the first failure.
self._retry_connect_tmr.set_timeout(self.FAST_CONNECT_RETRY_PERIOD_SEC)
elif self._connect_attempts == 2:
# If the fast connect re-try fails, then we can print a message to
# indicate the failure, and start a slow re-try period.
self._retry_connect_tmr.set_timeout(self.CONNECT_RETRY_PERIOD_SEC)
logging.error('%s Failed to connect to controller. %s %s', self.id, err.domain, err.message)
if self._should_try_to_reconnect():
logging.debug(
'Controller._on_connect_fail() - %s %s. Retry in %s sec.',
self.id,
err,
self._retry_connect_tmr.get_timeout(),
)
self._retry_connect_tmr.start()
else:
logging.debug(
'Controller._on_connect_fail() - %s Received event on dead object. %s %s',
self.id,
err.domain,
err.message,
)
def disconnect(self, disconnected_cb, keep_connection):
'''@brief Issue an asynchronous disconnect command to a Controller.
Once the async command has completed, the callback 'disconnected_cb'
will be invoked. If a controller is already disconnected, then the
callback will be added to the main loop's next idle slot to be executed
ASAP.
@param disconnected_cb: Callback to be called when disconnect has
completed. the callback must have this signature:
def cback(controller: Controller, success: bool)
@param keep_connection: Whether the underlying connection should remain
in the kernel.
'''
logging.debug(
'Controller.disconnect() - %s | %s: keep_connection=%s', self.id, self.device, keep_connection
)
if self._ctrl and self._ctrl.connected() and not keep_connection:
logging.info('%s | %s - Disconnect initiated', self.id, self.device)
op = gutil.AsyncTask(self._on_disconn_success, self._on_disconn_fail, self._ctrl.disconnect)
op.run_async(disconnected_cb)
else:
# Defer callback to the next main loop's idle period. The callback
# cannot be called directly as the current Controller object is in the
# process of being disconnected and the callback will in fact delete
# the object. This would invariably lead to unpredictable outcome.
GLib.idle_add(disconnected_cb, self, True)
def _on_disconn_success(self, op_obj: gutil.AsyncTask, data, disconnected_cb): # pylint: disable=unused-argument
logging.debug('Controller._on_disconn_success() - %s | %s', self.id, self.device)
op_obj.kill()
# Defer callback to the next main loop's idle period. The callback
# cannot be called directly as the current Controller object is in the
# process of being disconnected and the callback will in fact delete
# the object. This would invariably lead to unpredictable outcome.
GLib.idle_add(disconnected_cb, self, True)
def _on_disconn_fail(
self, op_obj: gutil.AsyncTask, err, fail_cnt, disconnected_cb
): # pylint: disable=unused-argument
logging.debug('Controller._on_disconn_fail() - %s | %s: %s', self.id, self.device, err)
op_obj.kill()
# Defer callback to the next main loop's idle period. The callback
# cannot be called directly as the current Controller object is in the
# process of being disconnected and the callback will in fact delete
# the object. This would invariably lead to unpredictable outcome.
GLib.idle_add(disconnected_cb, self, False)
# ******************************************************************************
class Dc(Controller):
'''@brief This object establishes a connection to one Discover Controller (DC).
It retrieves the discovery log pages and caches them.
It also monitors udev events associated with that DC and updates
the cached discovery log pages accordingly.
'''
GET_LOG_PAGE_RETRY_RERIOD_SEC = 20
REGISTRATION_RETRY_RERIOD_SEC = 5
GET_SUPPORTED_RETRY_RERIOD_SEC = 5
def __init__(self, staf, tid: trid.TID, log_pages=None, origin=None):
super().__init__(tid, staf, discovery_ctrl=True)
self._register_op = None
self._get_supported_op = None
self._get_log_op = None
self._origin = origin
self._log_pages = log_pages if log_pages else list() # Log pages cache
# For Avahi-discovered DCs that later become unresponsive, monitor how
# long the controller remains unresponsive and if it does not return for
# a configurable soak period (_ctrl_unresponsive_tmr), remove that
# controller. Only Avahi-discovered controllers need this timeout-based
# cleanup.
self._ctrl_unresponsive_time = None # The time at which connectivity was lost
self._ctrl_unresponsive_tmr = gutil.GTimer(0, self._serv.controller_unresponsive, self.tid)
def _release_resources(self):
logging.debug('Dc._release_resources() - %s | %s', self.id, self.device)
super()._release_resources()
if self._ctrl_unresponsive_tmr is not None:
self._ctrl_unresponsive_tmr.kill()
self._log_pages = list()
self._ctrl_unresponsive_tmr = None
def _kill_ops(self):
super()._kill_ops()
if self._get_log_op:
self._get_log_op.kill()
self._get_log_op = None
if self._register_op:
self._register_op.kill()
self._register_op = None
if self._get_supported_op:
self._get_supported_op.kill()
self._get_supported_op = None
def all_ops_completed(self) -> bool:
'''@brief Returns True if all operations have completed. False otherwise.'''
return (
super().all_ops_completed()
and (self._get_log_op is None or self._get_log_op.completed())
and (self._register_op is None or self._register_op.completed())
and (self._get_supported_op is None or self._get_supported_op.completed())
)
@property
def origin(self):
'''@brief Return how this controller came into existance. Was it
"discovered" through mDNS service discovery (TP8009), was it manually
"configured" in stafd.conf, or was it a "referral".
'''
return self._origin
@origin.setter
def origin(self, value):
'''@brief Set the origin of this controller.'''
if value in ('discovered', 'configured', 'referral'):
self._origin = value
self._handle_lost_controller()
else:
logging.error('%s | %s - Trying to set invalid origin to %s', self.id, self.device, value)
def reload_hdlr(self):
'''@brief This is called when a "reload" signal is received.'''
logging.debug('Dc.reload_hdlr() - %s | %s', self.id, self.device)
self._handle_lost_controller()
self._resync_with_controller()
def info(self) -> dict:
'''@brief Get the controller info for this object'''
timeout = conf.SvcConf().zeroconf_persistence_sec
unresponsive_time = (
time.asctime(self._ctrl_unresponsive_time) if self._ctrl_unresponsive_time is not None else '---'
)
info = super().info()
info['origin'] = self.origin
if self.origin == 'discovered':
# The code that handles "unresponsive" DCs only applies to
# discovered DCs. So, let's only print that info when it's relevant.
info['unresponsive timer'] = str(self._ctrl_unresponsive_tmr)
info['unresponsive timeout'] = f'{timeout} sec' if timeout >= 0 else 'forever'
info['unresponsive time'] = unresponsive_time
if self._get_log_op:
info['get log page operation'] = str(self._get_log_op.as_dict())
if self._register_op:
info['register operation'] = str(self._register_op.as_dict())
if self._get_supported_op:
info['get supported log page operation'] = str(self._get_supported_op.as_dict())
return info
def cancel(self):
'''@brief Used to cancel pending operations.'''
super().cancel()
if self._get_log_op:
self._get_log_op.cancel()
if self._register_op:
self._register_op.cancel()
if self._get_supported_op:
self._get_supported_op.cancel()
def log_pages(self) -> list:
'''@brief Get the cached log pages for this object'''
return self._log_pages
def referrals(self) -> list:
'''@brief Return the list of referrals'''
return [page for page in self._log_pages if page['subtype'] == 'referral']
def _is_ddc(self):
return self._ctrl and self._ctrl.dctype != 'cdc'
def _on_aen(self, aen: int):
if aen == DLP_CHANGED and self._get_log_op:
self._get_log_op.run_async()
def _handle_lost_controller(self):
if self.origin == 'discovered': # Only apply to mDNS-discovered DCs
if not self._serv.is_avahi_reported(self.tid) and not self.connected():
timeout = conf.SvcConf().zeroconf_persistence_sec
if timeout >= 0:
if self._ctrl_unresponsive_time is None:
self._ctrl_unresponsive_time = time.localtime()
self._ctrl_unresponsive_tmr.start(timeout)
logging.info(
'%s | %s - Controller is not responding. Will be removed by %s unless restored',
self.id,
self.device,
time.ctime(time.mktime(self._ctrl_unresponsive_time) + timeout),
)
return
logging.info(
'%s | %s - Controller not responding. Retrying...',
self.id,
self.device,
)
self._ctrl_unresponsive_time = None
self._ctrl_unresponsive_tmr.stop()
self._ctrl_unresponsive_tmr.set_timeout(0)
def is_unresponsive(self):
'''@brief For "discovered" DC, return True if DC is unresponsive,
False otherwise.
'''
return (
self.origin == 'discovered'
and not self._serv.is_avahi_reported(self.tid)
and not self.connected()
and self._ctrl_unresponsive_time is not None
and self._ctrl_unresponsive_tmr.time_remaining() <= 0
)
def _resync_with_controller(self):
'''Communicate with DC to resync the states'''
if self._register_op:
self._register_op.run_async()
elif self._get_supported_op:
self._get_supported_op.run_async()
elif self._get_log_op:
self._get_log_op.run_async()
def _on_nvme_event(self, nvme_event: str):
if nvme_event in ('connected', 'rediscover'):
# This event indicates that the kernel
# driver re-connected to the DC.
logging.debug(
'Dc._on_nvme_event() - %s | %s: Received "%s" event',
self.id,
self.device,
nvme_event,
)
self._resync_with_controller()
def _find_existing_connection(self):
return self._udev.find_nvme_dc_device(self.tid)
def _post_registration_actions(self):
# Need to check that supported_log_pages() is available (introduced in libnvme 1.2)
has_supported_log_pages = hasattr(self._ctrl, 'supported_log_pages')
if not has_supported_log_pages:
logging.warning(
'%s | %s - libnvme-%s does not support "Get supported log pages". Please upgrade libnvme.',
self.id,
self.device,
defs.LIBNVME_VERSION,
)
if conf.SvcConf().pleo_enabled and self._is_ddc() and has_supported_log_pages:
self._get_supported_op = gutil.AsyncTask(
self._on_get_supported_success, self._on_get_supported_fail, self._ctrl.supported_log_pages
)
self._get_supported_op.run_async()
else:
self._get_log_op = gutil.AsyncTask(self._on_get_log_success, self._on_get_log_fail, self._ctrl.discover)
self._get_log_op.run_async()
# --------------------------------------------------------------------------
def _on_connect_success(self, op_obj: gutil.AsyncTask, data):
'''@brief Function called when we successfully connect to the
Discovery Controller.
'''
super()._on_connect_success(op_obj, data)
if self._alive():
self._ctrl_unresponsive_time = None
self._ctrl_unresponsive_tmr.stop()
self._ctrl_unresponsive_tmr.set_timeout(0)
if self._ctrl.is_registration_supported():
self._register_op = gutil.AsyncTask(
self._on_registration_success,
self._on_registration_fail,
self._ctrl.registration_ctlr,
nvme.NVMF_DIM_TAS_REGISTER,
)
self._register_op.run_async()
else:
self._post_registration_actions()
def _on_connect_fail(self, op_obj: gutil.AsyncTask, err, fail_cnt):
'''@brief Function called when we fail to connect to the Controller.'''
super()._on_connect_fail(op_obj, err, fail_cnt)
if self._alive():
self._handle_lost_controller()
# --------------------------------------------------------------------------
def _on_registration_success(self, op_obj: gutil.AsyncTask, data): # pylint: disable=unused-argument
'''@brief Function called when we successfully register with the
Discovery Controller. See self._register_op object
for details.
NOTE: The name _on_registration_success() may be misleading. "success"
refers to the fact that a successful exchange was made with the DC.
It doesn't mean that the registration itself succeeded.
'''
if self._alive():
if data is not None:
logging.warning('%s | %s - Registration error. %s.', self.id, self.device, data)
else:
logging.debug('Dc._on_registration_success() - %s | %s', self.id, self.device)
self._post_registration_actions()
else:
logging.debug(
'Dc._on_registration_success() - %s | %s: Received event on dead object.', self.id, self.device
)
def _on_registration_fail(self, op_obj: gutil.AsyncTask, err, fail_cnt):
'''@brief Function called when we fail to register with the
Discovery Controller. See self._register_op object
for details.
'''
if self._alive():
logging.debug(
'Dc._on_registration_fail() - %s | %s: %s. Retry in %s sec',
self.id,
self.device,
err,
Dc.REGISTRATION_RETRY_RERIOD_SEC,
)
if fail_cnt == 1: # Throttle the logs. Only print the first time the command fails
logging.error('%s | %s - Failed to register with Discovery Controller. %s', self.id, self.device, err)
op_obj.retry(Dc.REGISTRATION_RETRY_RERIOD_SEC)
else:
logging.debug(
'Dc._on_registration_fail() - %s | %s: Received event on dead object. %s',
self.id,
self.device,
err,
)
op_obj.kill()
# --------------------------------------------------------------------------
def _on_get_supported_success(self, op_obj: gutil.AsyncTask, data): # pylint: disable=unused-argument
'''@brief Function called when we successfully retrieved the supported
log pages from the Discovery Controller. See self._get_supported_op object
for details.
NOTE: The name _on_get_supported_success() may be misleading. "success"
refers to the fact that a successful exchange was made with the DC.
It doesn't mean that the Get Supported Log Page itself succeeded.
'''
if self._alive():
try:
dlp_supp_opts = data[nvme.NVME_LOG_LID_DISCOVER] >> 16
except (TypeError, IndexError):
dlp_supp_opts = 0
logging.debug(
'Dc._on_get_supported_success() - %s | %s: supported options = 0x%04X = %s',
self.id,
self.device,
dlp_supp_opts,
dlp_supp_opts_as_string(dlp_supp_opts),
)
if 'lsp' in inspect.signature(self._ctrl.discover).parameters:
lsp = nvme.NVMF_LOG_DISC_LSP_PLEO if dlp_supp_opts & nvme.NVMF_LOG_DISC_LID_PLEOS else 0
self._get_log_op = gutil.AsyncTask(
self._on_get_log_success, self._on_get_log_fail, self._ctrl.discover, lsp
)
else:
logging.warning(
'%s | %s - libnvme-%s does not support setting PLEO bit. Please upgrade.',
self.id,
self.device,
defs.LIBNVME_VERSION,
)
self._get_log_op = gutil.AsyncTask(self._on_get_log_success, self._on_get_log_fail, self._ctrl.discover)
self._get_log_op.run_async()
else:
logging.debug(
'Dc._on_get_supported_success() - %s | %s: Received event on dead object.', self.id, self.device
)
def _on_get_supported_fail(self, op_obj: gutil.AsyncTask, err, fail_cnt):
'''@brief Function called when we fail to retrieve the supported log
page from the Discovery Controller. See self._get_supported_op object
for details.
'''
if self._alive():
logging.debug(
'Dc._on_get_supported_fail() - %s | %s: %s. Retry in %s sec',
self.id,
self.device,
err,
Dc.GET_SUPPORTED_RETRY_RERIOD_SEC,
)
if fail_cnt == 1: # Throttle the logs. Only print the first time the command fails
logging.error(
'%s | %s - Failed to Get supported log pages from Discovery Controller. %s',
self.id,
self.device,
err,
)
op_obj.retry(Dc.GET_SUPPORTED_RETRY_RERIOD_SEC)
else:
logging.debug(
'Dc._on_get_supported_fail() - %s | %s: Received event on dead object. %s',
self.id,
self.device,
err,
)
op_obj.kill()
# --------------------------------------------------------------------------
def _on_get_log_success(self, op_obj: gutil.AsyncTask, data): # pylint: disable=unused-argument
'''@brief Function called when we successfully retrieve the log pages
from the Discovery Controller. See self._get_log_op object
for details.
'''
if self._alive():
# Note that for historical reasons too long to explain, the CDC may
# return invalid addresses ('0.0.0.0', '::', or ''). Those need to
# be filtered out.
referrals_before = self.referrals()
self._log_pages = (
[
{k.strip(): str(v).strip() for k, v in dictionary.items()}
for dictionary in data
if dictionary.get('traddr', '').strip() not in ('0.0.0.0', '::', '')
]
if data
else list()
)
logging.info(
'%s | %s - Received discovery log pages (num records=%s).', self.id, self.device, len(self._log_pages)
)
referrals_after = self.referrals()
self._serv.log_pages_changed(self, self.device)
if referrals_after != referrals_before:
logging.debug(
'Dc._on_get_log_success() - %s | %s: Referrals before = %s',
self.id,
self.device,
referrals_before,
)
logging.debug(
'Dc._on_get_log_success() - %s | %s: Referrals after = %s',
self.id,
self.device,
referrals_after,
)
self._serv.referrals_changed()
else:
logging.debug(
'Dc._on_get_log_success() - %s | %s: Received event on dead object.', self.id, self.device
)
def _on_get_log_fail(self, op_obj: gutil.AsyncTask, err, fail_cnt):
'''@brief Function called when we fail to retrieve the log pages
from the Discovery Controller. See self._get_log_op object
for details.
'''
if self._alive():
logging.debug(
'Dc._on_get_log_fail() - %s | %s: %s. Retry in %s sec',
self.id,
self.device,
err,
Dc.GET_LOG_PAGE_RETRY_RERIOD_SEC,
)
if fail_cnt == 1: # Throttle the logs. Only print the first time the command fails
logging.error('%s | %s - Failed to retrieve log pages. %s', self.id, self.device, err)
op_obj.retry(Dc.GET_LOG_PAGE_RETRY_RERIOD_SEC)
else:
logging.debug(
'Dc._on_get_log_fail() - %s | %s: Received event on dead object. %s',
self.id,
self.device,
err,
)
op_obj.kill()
# ******************************************************************************
class Ioc(Controller):
'''@brief This object establishes a connection to one I/O Controller.'''
def __init__(self, stac, tid: trid.TID):
self._dlpe = None
super().__init__(tid, stac)
def _find_existing_connection(self):
return self._udev.find_nvme_ioc_device(self.tid)
def _on_aen(self, aen: int):
pass
def _on_nvme_event(self, nvme_event):
pass
def reload_hdlr(self):
'''@brief This is called when a "reload" signal is received.'''
if not self.connected() and self._retry_connect_tmr.time_remaining() == 0:
self._try_to_connect_deferred.schedule()
@property
def eflags(self):
'''@brief Return the eflag field of the DLPE'''
return get_eflags(self._dlpe)
@property
def ncc(self):
'''@brief Return Not Connected to CDC status'''
return get_ncc(self.eflags)
def details(self) -> dict:
'''@brief return detailed debug info about this controller'''
details = super().details()
details['dlpe'] = str(self._dlpe)
details['dlpe.eflags.ncc'] = str(self.ncc)
return details
def update_dlpe(self, dlpe):
'''@brief This method is called when a new DLPE associated
with this controller is received.'''
new_ncc = get_ncc(get_eflags(dlpe))
old_ncc = self.ncc
self._dlpe = dlpe
if old_ncc and not new_ncc: # NCC bit cleared?
if not self.connected():
self._connect_attempts = 0
self._try_to_connect_deferred.schedule()
def _should_try_to_reconnect(self):
'''@brief This is used to determine when it's time to stop trying toi connect'''
max_connect_attempts = conf.SvcConf().connect_attempts_on_ncc if self.ncc else 0
return max_connect_attempts == 0 or self._connect_attempts < max_connect_attempts

51
staslib/defs.py Normal file
View file

@ -0,0 +1,51 @@
# Copyright (c) 2021, 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>
''' @brief This file gets automagically configured by meson at build time.
'''
import os
import sys
import shutil
import platform
from staslib.version import KernelVersion
try:
import libnvme
LIBNVME_VERSION = libnvme.__version__
except (AttributeError, ModuleNotFoundError):
LIBNVME_VERSION = '?.?'
VERSION = '@VERSION@'
LICENSE = '@LICENSE@'
STACD_DBUS_NAME = '@STACD_DBUS_NAME@'
STACD_DBUS_PATH = '@STACD_DBUS_PATH@'
STAFD_DBUS_NAME = '@STAFD_DBUS_NAME@'
STAFD_DBUS_PATH = '@STAFD_DBUS_PATH@'
KERNEL_VERSION = KernelVersion(platform.release())
KERNEL_IFACE_MIN_VERSION = KernelVersion('5.14')
KERNEL_TP8013_MIN_VERSION = KernelVersion('5.16')
KERNEL_HOSTKEY_MIN_VERSION = KernelVersion('5.20')
KERNEL_CTRLKEY_MIN_VERSION = KernelVersion('5.20')
WELL_KNOWN_DISC_NQN = 'nqn.2014-08.org.nvmexpress.discovery'
PROG_NAME = os.path.basename(sys.argv[0])
NVME_HOSTID = '/etc/nvme/hostid'
NVME_HOSTNQN = '/etc/nvme/hostnqn'
NVME_HOSTKEY = '/etc/nvme/hostkey'
SYS_CONF_FILE = '/etc/stas/sys.conf'
STAFD_CONF_FILE = '/etc/stas/stafd.conf'
STACD_CONF_FILE = '/etc/stas/stacd.conf'
SYSTEMCTL = shutil.which('systemctl')

418
staslib/gutil.py Normal file
View file

@ -0,0 +1,418 @@
# 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>
#
'''This module provides utility functions/classes to provide easier to use
access to GLib/Gio/Gobject resources.
'''
import logging
from gi.repository import Gio, GLib, GObject
from staslib import conf, iputil, trid
# ******************************************************************************
class GTimer:
'''@brief Convenience class to wrap GLib timers'''
def __init__(
self, interval_sec: float = 0, user_cback=lambda: GLib.SOURCE_REMOVE, *user_data, priority=GLib.PRIORITY_DEFAULT
): # pylint: disable=keyword-arg-before-vararg
self._source = None
self._interval_sec = float(interval_sec)
self._user_cback = user_cback
self._user_data = user_data
self._priority = priority if priority is not None else GLib.PRIORITY_DEFAULT
def _release_resources(self):
self.stop()
self._user_cback = None
self._user_data = None
def kill(self):
'''@brief Used to release all resources associated with a timer.'''
self._release_resources()
def __str__(self):
if self._source is not None:
return f'{self._interval_sec}s [{self.time_remaining()}s]'
return f'{self._interval_sec}s [off]'
def _callback(self, *_):
retval = self._user_cback(*self._user_data)
if retval == GLib.SOURCE_REMOVE:
self._source = None
return retval
def stop(self):
'''@brief Stop timer'''
if self._source is not None:
self._source.destroy()
self._source = None
def start(self, new_interval_sec: float = -1.0):
'''@brief Start (or restart) timer'''
if new_interval_sec >= 0:
self._interval_sec = float(new_interval_sec)
if self._source is not None:
self._source.set_ready_time(
self._source.get_time() + (self._interval_sec * 1000000)
) # ready time is in micro-seconds (monotonic time)
else:
if self._interval_sec.is_integer():
self._source = GLib.timeout_source_new_seconds(int(self._interval_sec)) # seconds resolution
else:
self._source = GLib.timeout_source_new(self._interval_sec * 1000.0) # mili-seconds resolution
self._source.set_priority(self._priority)
self._source.set_callback(self._callback)
self._source.attach()
def clear(self):
'''@brief Make timer expire now. The callback function
will be invoked immediately by the main loop.
'''
if self._source is not None:
self._source.set_ready_time(0) # Expire now!
def set_callback(self, user_cback, *user_data):
'''@brief set the callback function to invoke when timer expires'''
self._user_cback = user_cback
self._user_data = user_data
def set_timeout(self, new_interval_sec: float):
'''@brief set the timer's duration'''
if new_interval_sec >= 0:
self._interval_sec = float(new_interval_sec)
def get_timeout(self):
'''@brief get the timer's duration'''
return self._interval_sec
def time_remaining(self) -> float:
'''@brief Get how much time remains on a timer before it fires.'''
if self._source is not None:
delta_us = self._source.get_ready_time() - self._source.get_time() # monotonic time in micro-seconds
if delta_us > 0:
return delta_us / 1000000.0
return 0
# ******************************************************************************
class NameResolver: # pylint: disable=too-few-public-methods
'''@brief DNS resolver to convert host names to IP addresses.'''
def __init__(self):
self._resolver = Gio.Resolver.get_default()
def resolve_ctrl_async(self, cancellable, controllers_in: list, callback):
'''@brief The traddr fields may specify a hostname instead of an IP
address. We need to resolve all the host names to addresses.
Resolving hostnames may take a while as a DNS server may need
to be contacted. For that reason, we're using async APIs with
callbacks to resolve all the hostnames.
The callback @callback will be called once all hostnames have
been resolved.
@param controllers: List of trid.TID
'''
pending_resolution_count = 0
controllers_out = []
service_conf = conf.SvcConf()
def addr_resolved(resolver, result, controller):
try:
addresses = resolver.lookup_by_name_finish(result) # List of Gio.InetAddress objects
except GLib.GError as err:
# We don't need to report "cancellation" errors.
if err.matches(Gio.io_error_quark(), Gio.IOErrorEnum.CANCELLED):
# pylint: disable=no-member
logging.debug('NameResolver.resolve_ctrl_async() - %s %s', err.message, controller)
else:
logging.error('%s', err.message) # pylint: disable=no-member
# if err.matches(Gio.resolver_error_quark(), Gio.ResolverError.TEMPORARY_FAILURE):
# elif err.matches(Gio.resolver_error_quark(), Gio.ResolverError.NOT_FOUND):
# elif err.matches(Gio.resolver_error_quark(), Gio.ResolverError.INTERNAL):
else:
traddr = None
# If multiple addresses are returned (which is often the case),
# prefer IPv4 addresses over IPv6.
if 4 in service_conf.ip_family:
for address in addresses:
# There may be multiple IPv4 addresses. Pick 1st one.
if address.get_family() == Gio.SocketFamily.IPV4:
traddr = address.to_string()
break
if traddr is None and 6 in service_conf.ip_family:
for address in addresses:
# There may be multiple IPv6 addresses. Pick 1st one.
if address.get_family() == Gio.SocketFamily.IPV6:
traddr = address.to_string()
break
if traddr is not None:
logging.debug(
'NameResolver.resolve_ctrl_async() - resolved \'%s\' -> %s', controller.traddr, traddr
)
cid = controller.as_dict()
cid['traddr'] = traddr
nonlocal controllers_out
controllers_out.append(trid.TID(cid))
# Invoke callback after all hostnames have been resolved
nonlocal pending_resolution_count
pending_resolution_count -= 1
if pending_resolution_count == 0:
callback(controllers_out)
for controller in controllers_in:
if controller.transport in ('tcp', 'rdma'):
hostname_or_addr = controller.traddr
if not hostname_or_addr:
logging.error('Invalid traddr: %s', controller)
else:
# Try to convert to an ipaddress object. If this
# succeeds, then we don't need to call the resolver.
ip = iputil.get_ipaddress_obj(hostname_or_addr)
if ip is None:
logging.debug('NameResolver.resolve_ctrl_async() - resolving \'%s\'', hostname_or_addr)
pending_resolution_count += 1
self._resolver.lookup_by_name_async(hostname_or_addr, cancellable, addr_resolved, controller)
elif ip.version in service_conf.ip_family:
controllers_out.append(controller)
else:
logging.warning(
'Excluding configured IP address %s based on "ip-family" setting', hostname_or_addr
)
else:
controllers_out.append(controller)
if pending_resolution_count == 0: # No names are pending asynchronous resolution
callback(controllers_out)
# ******************************************************************************
class _TaskRunner(GObject.Object):
'''@brief This class allows running methods asynchronously in a thread.'''
def __init__(self, user_function, *user_args):
'''@param user_function: function to run inside a thread
@param user_args: arguments passed to @user_function
'''
super().__init__()
self._user_function = user_function
self._user_args = user_args
def communicate(self, cancellable, cb_function, *cb_args):
'''@param cancellable: A Gio.Cancellable object that can be used to
cancel an in-flight async command.
@param cb_function: User callback function to call when the async
command has completed. The callback function
will be passed these arguments:
(runner, result, *cb_args)
Where:
runner: This _TaskRunner object instance
result: A GObject.Object instance that contains the result
cb_args: The cb_args arguments passed to communicate()
@param cb_args: User arguments to pass to @cb_function
'''
def in_thread_exec(task, self, task_data, cancellable): # pylint: disable=unused-argument
if task.return_error_if_cancelled():
return # Bail out if task has been cancelled
try:
value = GObject.Object()
value.result = self._user_function(*self._user_args)
task.return_value(value)
except Exception as ex: # pylint: disable=broad-except
task.return_error(GLib.Error(message=str(ex), domain=type(ex).__name__))
task = Gio.Task.new(self, cancellable, cb_function, *cb_args)
task.set_return_on_cancel(False)
task.run_in_thread(in_thread_exec)
return task
def communicate_finish(self, result): # pylint: disable=no-self-use
'''@brief Use this function in your callback (see @cb_function) to
extract data from the result object.
@return On success (True, data, None),
On failure (False, None, err: GLib.Error)
'''
try:
success, value = result.propagate_value()
return success, value.result, None
except GLib.Error as err:
return False, None, err
# ******************************************************************************
class AsyncTask: # pylint: disable=too-many-instance-attributes
'''Object used to manage an asynchronous GLib operation. The operation
can be cancelled or retried.
'''
def __init__(self, on_success_callback, on_failure_callback, operation, *op_args):
'''@param on_success_callback: Callback method invoked when @operation completes successfully
@param on_failure_callback: Callback method invoked when @operation fails
@param operation: Operation (i.e. a function) to execute asynchronously
@param op_args: Arguments passed to operation
'''
self._cancellable = Gio.Cancellable()
self._operation = operation
self._op_args = op_args
self._success_cb = on_success_callback
self._fail_cb = on_failure_callback
self._retry_tmr = None
self._errmsg = None
self._task = None
self._fail_cnt = 0
def _release_resources(self):
if self._alive():
self._cancellable.cancel()
if self._retry_tmr is not None:
self._retry_tmr.kill()
self._operation = None
self._op_args = None
self._success_cb = None
self._fail_cb = None
self._retry_tmr = None
self._errmsg = None
self._task = None
self._fail_cnt = None
self._cancellable = None
def __str__(self):
return str(self.as_dict())
def as_dict(self):
'''Return object members as a dictionary'''
info = {
'fail count': self._fail_cnt,
'completed': self._task.get_completed(),
'alive': self._alive(),
}
if self._retry_tmr:
info['retry timer'] = str(self._retry_tmr)
if self._errmsg:
info['error'] = self._errmsg
return info
def _alive(self):
return self._cancellable and not self._cancellable.is_cancelled()
def completed(self):
'''@brief Returns True if the task has completed, False otherwise.'''
return self._task is not None and self._task.get_completed()
def cancel(self):
'''@brief cancel async operation'''
if self._alive():
self._cancellable.cancel()
def kill(self):
'''@brief kill and clean up this object'''
self._release_resources()
def run_async(self, *args):
'''@brief
Method used to initiate an asynchronous operation with the
Controller. When the operation completes (or fails) the
callback method @_on_operation_complete() will be invoked.
'''
runner = _TaskRunner(self._operation, *self._op_args)
self._task = runner.communicate(self._cancellable, self._on_operation_complete, *args)
def retry(self, interval_sec, *args):
'''@brief Tell this object that the async operation is to be retried
in @interval_sec seconds.
'''
if self._retry_tmr is None:
self._retry_tmr = GTimer()
self._retry_tmr.set_callback(self._on_retry_timeout, *args)
self._retry_tmr.start(interval_sec)
def _on_retry_timeout(self, *args):
'''@brief
When an operation fails, the application has the option to
retry at a later time by calling the retry() method. The
retry() method starts a timer at the end of which the operation
will be executed again. This is the method that is called when
the timer expires.
'''
if self._alive():
self.run_async(*args)
return GLib.SOURCE_REMOVE
def _on_operation_complete(self, runner, result, *args):
'''@brief
This callback method is invoked when the operation with the
Controller has completed (be it successful or not).
'''
# The operation might have been cancelled.
# Only proceed if it hasn't been cancelled.
if self._operation is None or not self._alive():
return
success, data, err = runner.communicate_finish(result)
if success:
self._errmsg = None
self._fail_cnt = 0
self._success_cb(self, data, *args)
else:
self._errmsg = str(err)
self._fail_cnt += 1
self._fail_cb(self, err, self._fail_cnt, *args)
# ******************************************************************************
class Deferred:
'''Implement a deferred function call. A deferred is a function that gets
added to the main loop to be executed during the next idle slot.'''
def __init__(self, func, *user_data):
self._source = None
self._func = func
self._user_data = user_data
def schedule(self):
'''Schedule the function to be called by the main loop. If the
function is already scheduled, then do nothing'''
if not self.is_scheduled():
srce_id = GLib.idle_add(self._func, *self._user_data)
self._source = GLib.main_context_default().find_source_by_id(srce_id)
def is_scheduled(self):
'''Check if deferred is currently schedules to run'''
return self._source and not self._source.is_destroyed()
def cancel(self):
'''Remove deferred from main loop'''
if self.is_scheduled():
self._source.destroy()
self._source = None

169
staslib/iputil.py Normal file
View file

@ -0,0 +1,169 @@
# 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>
'''A collection of IP address and network interface utilities'''
import socket
import logging
import ipaddress
from staslib import conf
RTM_NEWADDR = 20
RTM_GETADDR = 22
NLM_F_REQUEST = 0x01
NLM_F_ROOT = 0x100
NLMSG_DONE = 3
IFLA_ADDRESS = 1
NLMSGHDR_SZ = 16
IFADDRMSG_SZ = 8
RTATTR_SZ = 4
# Netlink request (Get address command)
GETADDRCMD = (
# BEGIN: struct nlmsghdr
b'\0' * 4 # nlmsg_len (placeholder - actual length calculated below)
+ (RTM_GETADDR).to_bytes(2, byteorder='little', signed=False) # nlmsg_type
+ (NLM_F_REQUEST | NLM_F_ROOT).to_bytes(2, byteorder='little', signed=False) # nlmsg_flags
+ b'\0' * 2 # nlmsg_seq
+ b'\0' * 2 # nlmsg_pid
# END: struct nlmsghdr
+ b'\0' * 8 # struct ifaddrmsg
)
GETADDRCMD = len(GETADDRCMD).to_bytes(4, byteorder='little') + GETADDRCMD[4:] # nlmsg_len
# ******************************************************************************
def get_ipaddress_obj(ipaddr):
'''@brief Return a IPv4Address or IPv6Address depending on whether @ipaddr
is a valid IPv4 or IPv6 address. Return None otherwise.'''
try:
ip = ipaddress.ip_address(ipaddr)
except ValueError:
return None
return ip
# ******************************************************************************
def _data_matches_ip(data_family, data, ip):
if data_family == socket.AF_INET:
try:
other_ip = ipaddress.IPv4Address(data)
except ValueError:
return False
if ip.version == 6:
ip = ip.ipv4_mapped
elif data_family == socket.AF_INET6:
try:
other_ip = ipaddress.IPv6Address(data)
except ValueError:
return False
if ip.version == 4:
other_ip = other_ip.ipv4_mapped
else:
return False
return other_ip == ip
# ******************************************************************************
def iface_of(src_addr):
'''@brief Find the interface that has src_addr as one of its assigned IP addresses.
@param src_addr: The IP address to match
@type src_addr: Instance of ipaddress.IPv4Address or ipaddress.IPv6Address
'''
with socket.socket(socket.AF_NETLINK, socket.SOCK_RAW) as sock:
sock.sendall(GETADDRCMD)
nlmsg = sock.recv(8192)
nlmsg_idx = 0
while True:
if nlmsg_idx >= len(nlmsg):
nlmsg += sock.recv(8192)
nlmsg_type = int.from_bytes(nlmsg[nlmsg_idx + 4 : nlmsg_idx + 6], byteorder='little', signed=False)
if nlmsg_type == NLMSG_DONE:
break
if nlmsg_type != RTM_NEWADDR:
break
nlmsg_len = int.from_bytes(nlmsg[nlmsg_idx : nlmsg_idx + 4], byteorder='little', signed=False)
if nlmsg_len % 4: # Is msg length not a multiple of 4?
break
ifaddrmsg_indx = nlmsg_idx + NLMSGHDR_SZ
ifa_family = nlmsg[ifaddrmsg_indx]
ifa_index = int.from_bytes(nlmsg[ifaddrmsg_indx + 4 : ifaddrmsg_indx + 8], byteorder='little', signed=False)
rtattr_indx = ifaddrmsg_indx + IFADDRMSG_SZ
while rtattr_indx < (nlmsg_idx + nlmsg_len):
rta_len = int.from_bytes(nlmsg[rtattr_indx : rtattr_indx + 2], byteorder='little', signed=False)
rta_type = int.from_bytes(nlmsg[rtattr_indx + 2 : rtattr_indx + 4], byteorder='little', signed=False)
if rta_type == IFLA_ADDRESS:
data = nlmsg[rtattr_indx + RTATTR_SZ : rtattr_indx + rta_len]
if _data_matches_ip(ifa_family, data, src_addr):
return socket.if_indextoname(ifa_index)
rta_len = (rta_len + 3) & ~3 # Round up to multiple of 4
rtattr_indx += rta_len # Move to next rtattr
nlmsg_idx += nlmsg_len # Move to next Netlink message
return ''
# ******************************************************************************
def get_interface(src_addr):
'''Get interface for given source address
@param src_addr: The source address
@type src_addr: str
'''
if not src_addr:
return ''
src_addr = src_addr.split('%')[0] # remove scope-id (if any)
src_addr = get_ipaddress_obj(src_addr)
return '' if src_addr is None else iface_of(src_addr)
# ******************************************************************************
def remove_invalid_addresses(controllers: list):
'''@brief Remove controllers with invalid addresses from the list of controllers.
@param controllers: List of TIDs
'''
service_conf = conf.SvcConf()
valid_controllers = list()
for controller in controllers:
if controller.transport in ('tcp', 'rdma'):
# Let's make sure that traddr is
# syntactically a valid IPv4 or IPv6 address.
ip = get_ipaddress_obj(controller.traddr)
if ip is None:
logging.warning('%s IP address is not valid', controller)
continue
# Let's make sure the address family is enabled.
if ip.version not in service_conf.ip_family:
logging.debug(
'%s ignored because IPv%s is disabled in %s',
controller,
ip.version,
service_conf.conf_file,
)
continue
valid_controllers.append(controller)
elif controller.transport in ('fc', 'loop'):
# At some point, need to validate FC addresses as well...
valid_controllers.append(controller)
else:
logging.warning('Invalid transport %s', controller.transport)
return valid_controllers

53
staslib/log.py Normal file
View file

@ -0,0 +1,53 @@
# 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 logging module'''
import sys
import logging
from staslib import defs
def init(syslog: bool):
'''Init log module
@param syslog: True to send messages to the syslog,
False to send messages to stdout.
'''
log = logging.getLogger()
log.propagate = False
if syslog:
try:
# Try journal logger first
import systemd.journal # pylint: disable=import-outside-toplevel
handler = systemd.journal.JournalHandler(SYSLOG_IDENTIFIER=defs.PROG_NAME)
except ModuleNotFoundError:
# Go back to standard syslog handler
from logging.handlers import SysLogHandler # pylint: disable=import-outside-toplevel
handler = SysLogHandler(address="/dev/log")
handler.setFormatter(logging.Formatter(f'{defs.PROG_NAME}: %(message)s'))
else:
# Log to stdout
handler = logging.StreamHandler(stream=sys.stdout)
log.addHandler(handler)
log.setLevel(logging.INFO if syslog else logging.DEBUG)
def level() -> str:
'''@brief return current log level'''
logger = logging.getLogger()
return str(logging.getLevelName(logger.getEffectiveLevel()))
def set_level_from_tron(tron):
'''Set log level based on TRON'''
logger = logging.getLogger()
logger.setLevel(logging.DEBUG if tron else logging.INFO)

60
staslib/meson.build Normal file
View file

@ -0,0 +1,60 @@
# Copyright (c) 2021, 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>
#
files_to_configure = [ 'defs.py', '__init__.py', 'stafd.idl', 'stacd.idl' ]
configured_files = []
foreach file : files_to_configure
configured_files += configure_file(
input: file,
output: file,
configuration: conf
)
endforeach
files_to_copy = [
'avahi.py',
'conf.py',
'ctrl.py',
'gutil.py',
'iputil.py',
'log.py',
'service.py',
'singleton.py',
'stas.py',
'timeparse.py',
'trid.py',
'udev.py',
'version.py'
]
copied_files = []
foreach file : files_to_copy
copied_files += configure_file(
input: file,
output: file,
copy: true,
)
endforeach
files_to_install = copied_files + configured_files
python3.install_sources(
files_to_install,
pure: true,
subdir: 'staslib',
)
#===============================================================================
# Make a list of modules to lint
skip = ['stafd.idl', 'stacd.idl']
foreach file: files_to_install
fname = fs.name('@0@'.format(file))
if fname not in skip
modules_to_lint += file
endif
endforeach

878
staslib/service.py Normal file
View file

@ -0,0 +1,878 @@
# 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>
#
'''This module defines the base Service object from
which the Staf and the Stac objects are derived.'''
import json
import logging
import pathlib
import subprocess
from itertools import filterfalse
import dasbus.error
import dasbus.client.observer
import dasbus.client.proxy
from gi.repository import GLib
from systemd.daemon import notify as sd_notify
from staslib import avahi, conf, ctrl, defs, gutil, iputil, stas, timeparse, trid, udev
# ******************************************************************************
class CtrlTerminator:
'''The Controller Terminator is used to gracefully disconnect from
controllers. All communications with controllers is handled by the kernel.
Once we make a request to the kernel to perform an operation (e.g. connect),
we have to wait for it to complete before requesting another operation. This
is particularly important when we want to disconnect from a controller while
there are pending operations, especially a pending connect.
The "connect" operation is especially unpredictable because all connect
requests are made through the blocking interface "/dev/nvme-fabrics". This
means that once a "connect" operation has been submitted, and depending on
how many connect requests are made concurrently, it can take several seconds
for a connect to be processed by the kernel.
While connect or other operations are being performed, it is possible
that a disconnect may be requested (e.g. someone or something changes the
configuration to remove a controller). Because it is not possible to
terminate a pending operation request, we have to wait for it to complete
before we can issue a disconnect. Failure to do that will result in
operations being performed by the kernel in reverse order. For example,
a disconnect may be executed before a pending connect has had a chance to
complete. And this will result in controllers that are supposed to be
disconnected to be connected without nvme-stas knowing about it.
The Controller Terminator is used when we need to disconnect from a
controller. It will make sure that there are no pending operations before
issuing a disconnect.
'''
DISPOSAL_AUDIT_PERIOD_SEC = 30
def __init__(self):
self._udev = udev.UDEV
self._controllers = list() # The list of controllers to dispose of.
self._audit_tmr = gutil.GTimer(self.DISPOSAL_AUDIT_PERIOD_SEC, self._on_disposal_check)
def dispose(self, controller: ctrl.Controller, on_controller_removed_cb, keep_connection: bool):
'''Invoked by a service (stafd or stacd) to dispose of a controller'''
if controller.all_ops_completed():
logging.debug(
'CtrlTerminator.dispose() - %s | %s: Invoke disconnect()', controller.tid, controller.device
)
controller.disconnect(on_controller_removed_cb, keep_connection)
else:
logging.debug(
'CtrlTerminator.dispose() - %s | %s: Add controller to garbage disposal',
controller.tid,
controller.device,
)
self._controllers.append((controller, keep_connection, on_controller_removed_cb, controller.tid))
self._udev.register_for_action_events('add', self._on_kernel_events)
self._udev.register_for_action_events('remove', self._on_kernel_events)
if self._audit_tmr.time_remaining() == 0:
self._audit_tmr.start()
def pending_disposal(self, tid):
'''Check whether @tid is pending disposal'''
for controller in self._controllers:
if controller.tid == tid:
return True
return False
def info(self):
'''@brief Get info about this object (used for debug)'''
info = {
'terminator.audit timer': str(self._audit_tmr),
}
for controller, _, _, tid in self._controllers:
info[f'terminator.controller.{tid}'] = str(controller.info())
return info
def kill(self):
'''Stop Controller Terminator and release resources.'''
self._audit_tmr.stop()
self._audit_tmr = None
if self._udev:
self._udev.unregister_for_action_events('add', self._on_kernel_events)
self._udev.unregister_for_action_events('remove', self._on_kernel_events)
self._udev = None
for controller, keep_connection, on_controller_removed_cb, _ in self._controllers:
controller.disconnect(on_controller_removed_cb, keep_connection)
self._controllers.clear()
def _on_kernel_events(self, udev_obj):
logging.debug('CtrlTerminator._on_kernel_events() - %s event received', udev_obj.action)
self._disposal_check()
def _on_disposal_check(self, *_user_data):
logging.debug('CtrlTerminator._on_disposal_check()- Periodic audit')
return GLib.SOURCE_REMOVE if self._disposal_check() else GLib.SOURCE_CONTINUE
@staticmethod
def _keep_or_terminate(args):
'''Return False if controller is to be kept. True if controller
was terminated and can be removed from the list.'''
controller, keep_connection, on_controller_removed_cb, tid = args
if controller.all_ops_completed():
logging.debug(
'CtrlTerminator._keep_or_terminate()- %s | %s: Disconnecting controller',
tid,
controller.device,
)
controller.disconnect(on_controller_removed_cb, keep_connection)
return True
return False
def _disposal_check(self):
# Iterate over the list, terminating (disconnecting) those controllers
# that have no pending operations, and remove those controllers from the
# list (only keep controllers that still have operations pending).
self._controllers[:] = filterfalse(self._keep_or_terminate, self._controllers)
disposal_complete = len(self._controllers) == 0
if disposal_complete:
logging.debug('CtrlTerminator._disposal_check() - Disposal complete')
self._audit_tmr.stop()
self._udev.unregister_for_action_events('add', self._on_kernel_events)
self._udev.unregister_for_action_events('remove', self._on_kernel_events)
else:
self._audit_tmr.start() # Restart timer
return disposal_complete
# ******************************************************************************
class Service(stas.ServiceABC):
'''@brief Base class used to manage a STorage Appliance Service'''
def __init__(self, args, default_conf, reload_hdlr):
self._udev = udev.UDEV
self._terminator = CtrlTerminator()
super().__init__(args, default_conf, reload_hdlr)
def _release_resources(self):
logging.debug('Service._release_resources()')
super()._release_resources()
if self._terminator:
self._terminator.kill()
self._udev = None
self._terminator = None
def _disconnect_all(self):
'''Tell all controller objects to disconnect'''
keep_connections = self._keep_connections_on_exit()
controllers = self._controllers.values()
logging.debug(
'Service._stop_hdlr() - Controller count = %s, keep_connections = %s',
len(controllers),
keep_connections,
)
for controller in controllers:
self._terminator.dispose(controller, self._on_final_disconnect, keep_connections)
def info(self) -> dict:
'''@brief Get the status info for this object (used for debug)'''
info = super().info()
if self._terminator:
info.update(self._terminator.info())
return info
@stas.ServiceABC.tron.setter
def tron(self, value):
'''@brief Set Trace ON property'''
super(__class__, self.__class__).tron.__set__(self, value)
# ******************************************************************************
class Stac(Service):
'''STorage Appliance Connector (STAC)'''
CONF_STABILITY_LONG_SOAK_TIME_SEC = 10 # pylint: disable=invalid-name
ADD_EVENT_SOAK_TIME_SEC = 1
def __init__(self, args, dbus):
default_conf = {
('Global', 'tron'): False,
('Global', 'hdr-digest'): False,
('Global', 'data-digest'): False,
('Global', 'kato'): None, # None to let the driver decide the default
('Global', 'nr-io-queues'): None, # None to let the driver decide the default
('Global', 'nr-write-queues'): None, # None to let the driver decide the default
('Global', 'nr-poll-queues'): None, # None to let the driver decide the default
('Global', 'queue-size'): None, # None to let the driver decide the default
('Global', 'reconnect-delay'): None, # None to let the driver decide the default
('Global', 'ctrl-loss-tmo'): None, # None to let the driver decide the default
('Global', 'disable-sqflow'): None, # None to let the driver decide the default
('Global', 'ignore-iface'): False,
('Global', 'ip-family'): (4, 6),
('Controllers', 'controller'): list(),
('Controllers', 'exclude'): list(),
('I/O controller connection management', 'disconnect-scope'): 'only-stas-connections',
('I/O controller connection management', 'disconnect-trtypes'): ['tcp'],
('I/O controller connection management', 'connect-attempts-on-ncc'): 0,
}
super().__init__(args, default_conf, self._reload_hdlr)
self._add_event_soak_tmr = gutil.GTimer(self.ADD_EVENT_SOAK_TIME_SEC, self._on_add_event_soaked)
self._config_connections_audit()
# Create the D-Bus instance.
self._config_dbus(dbus, defs.STACD_DBUS_NAME, defs.STACD_DBUS_PATH)
# Connect to STAF D-Bus interface
self._staf = None
self._staf_watcher = dasbus.client.observer.DBusObserver(self._sysbus, defs.STAFD_DBUS_NAME)
self._staf_watcher.service_available.connect(self._connect_to_staf)
self._staf_watcher.service_unavailable.connect(self._disconnect_from_staf)
self._staf_watcher.connect_once_available()
def _release_resources(self):
logging.debug('Stac._release_resources()')
if self._add_event_soak_tmr:
self._add_event_soak_tmr.kill()
if self._udev:
self._udev.unregister_for_action_events('add', self._on_add_event)
self._destroy_staf_comlink(self._staf_watcher)
if self._staf_watcher is not None:
self._staf_watcher.disconnect()
super()._release_resources()
self._staf = None
self._staf_watcher = None
self._add_event_soak_tmr = None
def _dump_last_known_config(self, controllers):
config = list(controllers.keys())
logging.debug('Stac._dump_last_known_config() - IOC count = %s', len(config))
self._write_lkc(config)
def _load_last_known_config(self):
config = self._read_lkc() or list()
logging.debug('Stac._load_last_known_config() - IOC count = %s', len(config))
controllers = {}
for tid in config:
# Only create Ioc objects if there is already a connection in the kernel
# First, regenerate the TID (in case of soft. upgrade and TID object
# has changed internally)
tid = trid.TID(tid.as_dict())
if udev.UDEV.find_nvme_ioc_device(tid) is not None:
controllers[tid] = ctrl.Ioc(self, tid)
return controllers
def _audit_all_connections(self, tids):
'''A host should only connect to I/O controllers that have been zoned
for that host or a manual "controller" entry exists in stacd.conf.
A host should disconnect from an I/O controller when that I/O controller
is removed from the zone or a "controller" entry is manually removed
from stacd.conf. stacd will audit connections if "disconnect-scope=
all-connections-matching-disconnect-trtypes". stacd will delete any
connection that is not supposed to exist.
'''
logging.debug('Stac._audit_all_connections() - tids = %s', tids)
num_controllers = len(self._controllers)
for tid in tids:
if tid not in self._controllers and not self._terminator.pending_disposal(tid):
self._controllers[tid] = ctrl.Ioc(self, tid)
if num_controllers != len(self._controllers):
self._cfg_soak_tmr.start(self.CONF_STABILITY_SOAK_TIME_SEC)
def _on_add_event(self, udev_obj):
'''@brief This function is called when a "add" event is received from
the kernel for an NVMe device. This is used to trigger an audit and make
sure that the connection to an I/O controller is allowed.
WARNING: There is a race condition with the "add" event from the kernel.
The kernel sends the "add" event a bit early and the sysfs attributes
associated with the nvme object are not always fully initialized.
To workaround this problem we use a soaking timer to give time for the
sysfs attributes to stabilize.
'''
logging.debug('Stac._on_add_event(() - Received "add" event: %s', udev_obj.sys_name)
self._add_event_soak_tmr.start()
def _on_add_event_soaked(self):
'''@brief After the add event has been soaking for ADD_EVENT_SOAK_TIME_SEC
seconds, we can audit the connections.
'''
if self._alive():
svc_conf = conf.SvcConf()
if svc_conf.disconnect_scope == 'all-connections-matching-disconnect-trtypes':
self._audit_all_connections(self._udev.get_nvme_ioc_tids(svc_conf.disconnect_trtypes))
return GLib.SOURCE_REMOVE
def _config_connections_audit(self):
'''This function checks the "disconnect_scope" parameter to determine
whether audits should be performed. Audits are enabled when
"disconnect_scope == all-connections-matching-disconnect-trtypes".
'''
svc_conf = conf.SvcConf()
if svc_conf.disconnect_scope == 'all-connections-matching-disconnect-trtypes':
if not self._udev.is_action_cback_registered('add', self._on_add_event):
self._udev.register_for_action_events('add', self._on_add_event)
self._audit_all_connections(self._udev.get_nvme_ioc_tids(svc_conf.disconnect_trtypes))
else:
self._udev.unregister_for_action_events('add', self._on_add_event)
def _keep_connections_on_exit(self):
'''@brief Determine whether connections should remain when the
process exits.
'''
return True
def _reload_hdlr(self):
'''@brief Reload configuration file. This is triggered by the SIGHUP
signal, which can be sent with "systemctl reload stacd".
'''
if not self._alive():
return GLib.SOURCE_REMOVE
sd_notify('RELOADING=1')
service_cnf = conf.SvcConf()
service_cnf.reload()
self.tron = service_cnf.tron
self._config_connections_audit()
self._cfg_soak_tmr.start(self.CONF_STABILITY_SOAK_TIME_SEC)
for controller in self._controllers.values():
controller.reload_hdlr()
sd_notify('READY=1')
return GLib.SOURCE_CONTINUE
def _get_log_pages_from_stafd(self):
if self._staf:
try:
return json.loads(self._staf.get_all_log_pages(True))
except dasbus.error.DBusError:
pass
return list()
def _config_ctrls_finish(self, configured_ctrl_list: list): # pylint: disable=too-many-locals
'''@param configured_ctrl_list: list of TIDs'''
# This is a callback function, which may be called after the service
# has been signalled to stop. So let's make sure the service is still
# alive and well before continuing.
if not self._alive():
logging.debug('Stac._config_ctrls_finish() - Exiting because service is no longer alive')
return
# Eliminate invalid entries from stacd.conf "controller list".
configured_ctrl_list = [
tid for tid in configured_ctrl_list if '' not in (tid.transport, tid.traddr, tid.trsvcid, tid.subsysnqn)
]
logging.debug('Stac._config_ctrls_finish() - configured_ctrl_list = %s', configured_ctrl_list)
discovered_ctrls = dict()
for staf_data in self._get_log_pages_from_stafd():
host_traddr = staf_data['discovery-controller']['host-traddr']
host_iface = staf_data['discovery-controller']['host-iface']
for dlpe in staf_data['log-pages']:
if dlpe.get('subtype') == 'nvme': # eliminate discovery controllers
tid = stas.tid_from_dlpe(dlpe, host_traddr, host_iface)
discovered_ctrls[tid] = dlpe
discovered_ctrl_list = list(discovered_ctrls.keys())
logging.debug('Stac._config_ctrls_finish() - discovered_ctrl_list = %s', discovered_ctrl_list)
controllers = stas.remove_excluded(configured_ctrl_list + discovered_ctrl_list)
controllers = iputil.remove_invalid_addresses(controllers)
new_controller_tids = set(controllers)
cur_controller_tids = set(self._controllers.keys())
controllers_to_add = new_controller_tids - cur_controller_tids
controllers_to_del = cur_controller_tids - new_controller_tids
logging.debug('Stac._config_ctrls_finish() - controllers_to_add = %s', list(controllers_to_add))
logging.debug('Stac._config_ctrls_finish() - controllers_to_del = %s', list(controllers_to_del))
svc_conf = conf.SvcConf()
no_disconnect = svc_conf.disconnect_scope == 'no-disconnect'
match_trtypes = svc_conf.disconnect_scope == 'all-connections-matching-disconnect-trtypes'
logging.debug(
'Stac._config_ctrls_finish() - no_disconnect=%s, match_trtypes=%s, svc_conf.disconnect_trtypes=%s',
no_disconnect,
match_trtypes,
svc_conf.disconnect_trtypes,
)
for tid in controllers_to_del:
controller = self._controllers.pop(tid, None)
if controller is not None:
keep_connection = no_disconnect or (match_trtypes and tid.transport not in svc_conf.disconnect_trtypes)
self._terminator.dispose(controller, self.remove_controller, keep_connection)
for tid in controllers_to_add:
self._controllers[tid] = ctrl.Ioc(self, tid)
for tid, controller in self._controllers.items():
if tid in discovered_ctrls:
dlpe = discovered_ctrls[tid]
controller.update_dlpe(dlpe)
self._dump_last_known_config(self._controllers)
def _connect_to_staf(self, _):
'''@brief Hook up DBus signal handlers for signals from stafd.'''
if not self._alive():
return
try:
self._staf = self._sysbus.get_proxy(defs.STAFD_DBUS_NAME, defs.STAFD_DBUS_PATH)
self._staf.log_pages_changed.connect(self._log_pages_changed)
self._staf.dc_removed.connect(self._dc_removed)
self._cfg_soak_tmr.start()
# Make sure timer is set back to its normal value.
self._cfg_soak_tmr.set_timeout(self.CONF_STABILITY_SOAK_TIME_SEC)
logging.debug('Stac._connect_to_staf() - Connected to staf')
except dasbus.error.DBusError:
logging.error('Failed to connect to staf')
def _destroy_staf_comlink(self, watcher): # pylint: disable=unused-argument
if self._staf:
self._staf.log_pages_changed.disconnect(self._log_pages_changed)
self._staf.dc_removed.disconnect(self._dc_removed)
dasbus.client.proxy.disconnect_proxy(self._staf)
self._staf = None
def _disconnect_from_staf(self, watcher):
self._destroy_staf_comlink(watcher)
# When we lose connectivity with stafd, the most logical explanation
# is that stafd restarted. In that case, it may take some time for stafd
# to re-populate its log pages cache. So let's give stafd plenty of time
# to update its log pages cache and send log pages change notifications
# before triggering a stacd re-config. We do this by momentarily
# increasing the config soak timer to a longer period.
if self._cfg_soak_tmr:
self._cfg_soak_tmr.set_timeout(self.CONF_STABILITY_LONG_SOAK_TIME_SEC)
logging.debug('Stac._disconnect_from_staf() - Disconnected from staf')
def _log_pages_changed( # pylint: disable=too-many-arguments
self, transport, traddr, trsvcid, host_traddr, host_iface, subsysnqn, device
):
if not self._alive():
return
logging.debug(
'Stac._log_pages_changed() - transport=%s, traddr=%s, trsvcid=%s, host_traddr=%s, host_iface=%s, subsysnqn=%s, device=%s',
transport,
traddr,
trsvcid,
host_traddr,
host_iface,
subsysnqn,
device,
)
if self._cfg_soak_tmr:
self._cfg_soak_tmr.start(self.CONF_STABILITY_SOAK_TIME_SEC)
def _dc_removed(self):
if not self._alive():
return
logging.debug('Stac._dc_removed()')
if self._cfg_soak_tmr:
self._cfg_soak_tmr.start(self.CONF_STABILITY_SOAK_TIME_SEC)
# ******************************************************************************
# Only keep legacy FC rule (not even sure this is still in use today, but just to be safe).
UDEV_RULE_OVERRIDE = r'''
ACTION=="change", SUBSYSTEM=="fc", ENV{FC_EVENT}=="nvmediscovery", \
ENV{NVMEFC_HOST_TRADDR}=="*", ENV{NVMEFC_TRADDR}=="*", \
RUN+="%s --no-block start nvmf-connect@--transport=fc\t--traddr=$env{NVMEFC_TRADDR}\t--trsvcid=none\t--host-traddr=$env{NVMEFC_HOST_TRADDR}.service"
'''
def _udev_rule_ctrl(suppress):
'''@brief We override the standard udev rule installed by nvme-cli, i.e.
'/usr/lib/udev/rules.d/70-nvmf-autoconnect.rules', with a copy into
/run/udev/rules.d. The goal is to suppress the udev rule that controls TCP
connections to I/O controllers. This is to avoid race conditions between
stacd and udevd. This is configurable. See "udev-rule" in stacd.conf
for details.
@param enable: When True, override nvme-cli's udev rule and prevent TCP I/O
Controller connections by nvme-cli. When False, allow nvme-cli's udev rule
to make TCP I/O connections.
@type enable: bool
'''
udev_rule_file = pathlib.Path('/run/udev/rules.d', '70-nvmf-autoconnect.rules')
if suppress:
if not udev_rule_file.exists():
pathlib.Path('/run/udev/rules.d').mkdir(parents=True, exist_ok=True)
text = UDEV_RULE_OVERRIDE % (defs.SYSTEMCTL)
udev_rule_file.write_text(text) # pylint: disable=unspecified-encoding
else:
try:
udev_rule_file.unlink()
except FileNotFoundError:
pass
def _is_dlp_changed_aen(udev_obj):
'''Check whether we received a Change of Discovery Log Page AEN'''
nvme_aen = udev_obj.get('NVME_AEN')
if not isinstance(nvme_aen, str):
return False
aen = int(nvme_aen, 16)
if aen != ctrl.DLP_CHANGED:
return False
logging.info(
'%s - Received AEN: Change of Discovery Log Page (%s)',
udev_obj.sys_name,
nvme_aen,
)
return True
def _event_matches(udev_obj, nvme_events):
'''Check whether we received an NVMe Event matching
one of the events listed in @nvme_events'''
nvme_event = udev_obj.get('NVME_EVENT')
if nvme_event not in nvme_events:
return False
logging.info('%s - Received "%s" event', udev_obj.sys_name, nvme_event)
return True
# ******************************************************************************
class Staf(Service):
'''STorage Appliance Finder (STAF)'''
def __init__(self, args, dbus):
default_conf = {
('Global', 'tron'): False,
('Global', 'hdr-digest'): False,
('Global', 'data-digest'): False,
('Global', 'kato'): 30,
('Global', 'queue-size'): None, # None to let the driver decide the default
('Global', 'reconnect-delay'): None, # None to let the driver decide the default
('Global', 'ctrl-loss-tmo'): None, # None to let the driver decide the default
('Global', 'disable-sqflow'): None, # None to let the driver decide the default
('Global', 'persistent-connections'): False, # Deprecated
('Discovery controller connection management', 'persistent-connections'): True,
('Discovery controller connection management', 'zeroconf-connections-persistence'): timeparse.timeparse(
'72hours'
),
('Global', 'ignore-iface'): False,
('Global', 'ip-family'): (4, 6),
('Global', 'pleo'): True,
('Service Discovery', 'zeroconf'): True,
('Controllers', 'controller'): list(),
('Controllers', 'exclude'): list(),
}
super().__init__(args, default_conf, self._reload_hdlr)
self._avahi = avahi.Avahi(self._sysbus, self._avahi_change)
self._avahi.config_stypes(conf.SvcConf().stypes)
# Create the D-Bus instance.
self._config_dbus(dbus, defs.STAFD_DBUS_NAME, defs.STAFD_DBUS_PATH)
self._udev.register_for_action_events('change', self._nvme_cli_interop)
_udev_rule_ctrl(True)
def info(self) -> dict:
'''@brief Get the status info for this object (used for debug)'''
info = super().info()
info['avahi'] = self._avahi.info()
return info
def _release_resources(self):
logging.debug('Staf._release_resources()')
if self._udev:
self._udev.unregister_for_action_events('change', self._nvme_cli_interop)
super()._release_resources()
_udev_rule_ctrl(False)
if self._avahi:
self._avahi.kill()
self._avahi = None
def _dump_last_known_config(self, controllers):
config = {tid: {'log_pages': dc.log_pages(), 'origin': dc.origin} for tid, dc in controllers.items()}
logging.debug('Staf._dump_last_known_config() - DC count = %s', len(config))
self._write_lkc(config)
def _load_last_known_config(self):
config = self._read_lkc() or dict()
logging.debug('Staf._load_last_known_config() - DC count = %s', len(config))
controllers = {}
for tid, data in config.items():
if isinstance(data, dict):
log_pages = data.get('log_pages')
origin = data.get('origin')
else:
log_pages = data
origin = None
# Regenerate the TID (in case of soft. upgrade and TID object
# has changed internally)
tid = trid.TID(tid.as_dict())
controllers[tid] = ctrl.Dc(self, tid, log_pages, origin)
return controllers
def _keep_connections_on_exit(self):
'''@brief Determine whether connections should remain when the
process exits.
'''
return conf.SvcConf().persistent_connections
def _reload_hdlr(self):
'''@brief Reload configuration file. This is triggered by the SIGHUP
signal, which can be sent with "systemctl reload stafd".
'''
if not self._alive():
return GLib.SOURCE_REMOVE
sd_notify('RELOADING=1')
service_cnf = conf.SvcConf()
service_cnf.reload()
self.tron = service_cnf.tron
self._avahi.kick_start() # Make sure Avahi is running
self._avahi.config_stypes(service_cnf.stypes)
self._cfg_soak_tmr.start()
for controller in self._controllers.values():
controller.reload_hdlr()
sd_notify('READY=1')
return GLib.SOURCE_CONTINUE
def is_avahi_reported(self, tid):
'''@brief Return whether @tid is being reported by the Avahi daemon.
@return: True if the Avahi daemon is reporting it, False otherwise.
'''
for cid in self._avahi.get_controllers():
if trid.TID(cid) == tid:
return True
return False
def log_pages_changed(self, controller, device):
'''@brief Function invoked when a controller's cached log pages
have changed. This will emit a D-Bus signal to inform
other applications that the cached log pages have changed.
'''
self._dbus_iface.log_pages_changed.emit(
controller.tid.transport,
controller.tid.traddr,
controller.tid.trsvcid,
controller.tid.host_traddr,
controller.tid.host_iface,
controller.tid.subsysnqn,
device,
)
def dc_removed(self):
'''@brief Function invoked when a controller's cached log pages
have changed. This will emit a D-Bus signal to inform
other applications that the cached log pages have changed.
'''
self._dbus_iface.dc_removed.emit()
def _referrals(self) -> list:
return [
stas.tid_from_dlpe(dlpe, controller.tid.host_traddr, controller.tid.host_iface)
for controller in self.get_controllers()
for dlpe in controller.referrals()
]
def _config_ctrls_finish(self, configured_ctrl_list: list):
'''@brief Finish discovery controllers configuration after
hostnames (if any) have been resolved. All the logic associated
with discovery controller creation/deletion is found here. To
avoid calling this algorith repetitively for each and every events,
it is called after a soaking period controlled by self._cfg_soak_tmr.
@param configured_ctrl_list: List of TIDs configured in stafd.conf with
all hostnames resolved to their corresponding IP addresses.
'''
# This is a callback function, which may be called after the service
# has been signalled to stop. So let's make sure the service is still
# alive and well before continuing.
if not self._alive():
logging.debug('Staf._config_ctrls_finish() - Exiting because service is no longer alive')
return
# Eliminate invalid entries from stafd.conf "controller list".
controllers = list()
for tid in configured_ctrl_list:
if '' in (tid.transport, tid.traddr, tid.trsvcid):
continue
if not tid.subsysnqn:
cid = tid.as_dict()
cid['subsysnqn'] = defs.WELL_KNOWN_DISC_NQN
controllers.append(trid.TID(cid))
else:
controllers.append(tid)
configured_ctrl_list = controllers
# Get the Avahi-discovered list and the referrals.
discovered_ctrl_list = [trid.TID(cid) for cid in self._avahi.get_controllers()]
referral_ctrl_list = self._referrals()
logging.debug('Staf._config_ctrls_finish() - configured_ctrl_list = %s', configured_ctrl_list)
logging.debug('Staf._config_ctrls_finish() - discovered_ctrl_list = %s', discovered_ctrl_list)
logging.debug('Staf._config_ctrls_finish() - referral_ctrl_list = %s', referral_ctrl_list)
all_ctrls = configured_ctrl_list + discovered_ctrl_list + referral_ctrl_list
controllers = stas.remove_excluded(all_ctrls)
controllers = iputil.remove_invalid_addresses(controllers)
new_controller_tids = set(controllers)
cur_controller_tids = set(self._controllers.keys())
controllers_to_add = new_controller_tids - cur_controller_tids
controllers_to_del = cur_controller_tids - new_controller_tids
# Make a list list of excluded and invalid controllers
must_remove_list = set(all_ctrls) - new_controller_tids
# Find "discovered" controllers that have not responded
# in a while and add them to controllers that must be removed.
must_remove_list.update({tid for tid, controller in self._controllers.items() if controller.is_unresponsive()})
# Do not remove Avahi-discovered DCs from controllers_to_del unless
# marked as "must-be-removed" (must_remove_list). This is to account for
# the case where mDNS discovery is momentarily disabled (e.g. Avahi
# daemon restarts). We don't want to delete connections because of
# temporary mDNS impairments. Removal of Avahi-discovered DCs will be
# handled differently and only if the connection cannot be established
# for a long period of time.
logging.debug('Staf._config_ctrls_finish() - must_remove_list = %s', list(must_remove_list))
controllers_to_del = {
tid
for tid in controllers_to_del
if tid in must_remove_list or self._controllers[tid].origin != 'discovered'
}
logging.debug('Staf._config_ctrls_finish() - controllers_to_add = %s', list(controllers_to_add))
logging.debug('Staf._config_ctrls_finish() - controllers_to_del = %s', list(controllers_to_del))
# Delete controllers
for tid in controllers_to_del:
controller = self._controllers.pop(tid, None)
if controller is not None:
self._terminator.dispose(controller, self.remove_controller, keep_connection=False)
if len(controllers_to_del) > 0:
self.dc_removed() # Let other apps (e.g. stacd) know that discovery controllers were removed.
# Add controllers
for tid in controllers_to_add:
self._controllers[tid] = ctrl.Dc(self, tid)
# Update "origin" on all DC objects
for tid, controller in self._controllers.items():
origin = (
'configured'
if tid in configured_ctrl_list
else 'referral'
if tid in referral_ctrl_list
else 'discovered'
if tid in discovered_ctrl_list
else None
)
if origin is not None:
controller.origin = origin
self._dump_last_known_config(self._controllers)
def _avahi_change(self):
if self._alive() and self._cfg_soak_tmr is not None:
self._cfg_soak_tmr.start()
def controller_unresponsive(self, tid):
'''@brief Function invoked when a controller becomes unresponsive and
needs to be removed.
'''
if self._alive() and self._cfg_soak_tmr is not None:
logging.debug('Staf.controller_unresponsive() - tid = %s', tid)
self._cfg_soak_tmr.start()
def referrals_changed(self):
'''@brief Function invoked when a controller's cached referrals
have changed.
'''
if self._alive() and self._cfg_soak_tmr is not None:
logging.debug('Staf.referrals_changed()')
self._cfg_soak_tmr.start()
def _nvme_cli_interop(self, udev_obj):
'''Interoperability with nvme-cli:
stafd will invoke nvme-cli's connect-all the same way nvme-cli's udev
rules would do normally. This is for the case where a user has an hybrid
configuration where some controllers are configured through nvme-stas
and others through nvme-cli. This is not an optimal configuration. It
would be better if everything was configured through nvme-stas, however
support for hybrid configuration was requested by users (actually only
one user requested this).'''
# Looking for 'change' events only
if udev_obj.action != 'change':
return
# Looking for events from Discovery Controllers only
if not udev.Udev.is_dc_device(udev_obj):
return
# Is the controller already being monitored by stafd?
for controller in self.get_controllers():
if controller.device == udev_obj.sys_name:
return
# Did we receive a Change of DLP AEN or an NVME Event indicating 'connect' or 'rediscover'?
if not _is_dlp_changed_aen(udev_obj) and not _event_matches(udev_obj, ('connected', 'rediscover')):
return
# We need to invoke "nvme connect-all" using nvme-cli's nvmf-connect@.service
# NOTE: Eventually, we'll be able to drop --host-traddr and --host-iface from
# the parameters passed to nvmf-connect@.service. A fix was added to connect-all
# to infer these two values from the device used to connect to the DC.
# Ref: https://github.com/linux-nvme/nvme-cli/pull/1812
cnf = [
('--device', udev_obj.sys_name),
('--host-traddr', udev_obj.properties.get('NVME_HOST_TRADDR', None)),
('--host-iface', udev_obj.properties.get('NVME_HOST_IFACE', None)),
]
# Use systemd's escaped syntax (i.e. '=' is replaced by '\x3d', '\t' by '\x09', etc.
options = r'\x09'.join(
[fr'{option}\x3d{value}' for option, value in cnf if value not in (None, 'none', 'None', '')]
)
logging.info('Invoking: systemctl start nvmf-connect@%s.service', options)
cmd = [defs.SYSTEMCTL, '--quiet', '--no-block', 'start', fr'nvmf-connect@{options}.service']
subprocess.run(cmd, check=False)

23
staslib/singleton.py Normal file
View file

@ -0,0 +1,23 @@
# 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>
#
'''Implementation of a singleton pattern'''
class Singleton(type):
'''metaclass implementation of a singleton pattern'''
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
# This variable declaration is required to force a
# strong reference on the instance.
instance = super(Singleton, cls).__call__(*args, **kwargs)
cls._instances[cls] = instance
return cls._instances[cls]

27
staslib/stacd.idl Normal file
View file

@ -0,0 +1,27 @@
<node>
<interface name="@STACD_DBUS_NAME@.debug">
<property name="tron" type="b" access="readwrite"/>
<property name="log_level" type="s" access="read"/>
<method name="process_info">
<arg direction="out" type="s" name="info_json"/>
</method>
<method name="controller_info">
<arg direction="in" type="s" name="transport"/>
<arg direction="in" type="s" name="traddr"/>
<arg direction="in" type="s" name="trsvcid"/>
<arg direction="in" type="s" name="host_traddr"/>
<arg direction="in" type="s" name="host_iface"/>
<arg direction="in" type="s" name="subsysnqn"/>
<arg direction="out" type="s" name="info_json"/>
</method>
</interface>
<interface name="@STACD_DBUS_NAME@">
<method name="list_controllers">
<arg direction="in" type="b" name="detailed"/>
<arg direction="out" type="aa{ss}" name="controller_list"/>
</method>
</interface>
</node>

49
staslib/stafd.idl Normal file
View file

@ -0,0 +1,49 @@
<node>
<interface name="@STAFD_DBUS_NAME@.debug">
<property name="tron" type="b" access="readwrite"/>
<property name="log_level" type="s" access="read"/>
<method name="process_info">
<arg direction="out" type="s" name="info_json"/>
</method>
<method name="controller_info">
<arg direction="in" type="s" name="transport"/>
<arg direction="in" type="s" name="traddr"/>
<arg direction="in" type="s" name="trsvcid"/>
<arg direction="in" type="s" name="host_traddr"/>
<arg direction="in" type="s" name="host_iface"/>
<arg direction="in" type="s" name="subsysnqn"/>
<arg direction="out" type="s" name="info_json"/>
</method>
</interface>
<interface name="@STAFD_DBUS_NAME@">
<method name="list_controllers">
<arg direction="in" type="b" name="detailed"/>
<arg direction="out" type="aa{ss}" name="controller_list"/>
</method>
<method name="get_log_pages">
<arg direction="in" type="s" name="transport"/>
<arg direction="in" type="s" name="traddr"/>
<arg direction="in" type="s" name="trsvcid"/>
<arg direction="in" type="s" name="host_traddr"/>
<arg direction="in" type="s" name="host_iface"/>
<arg direction="in" type="s" name="subsysnqn"/>
<arg direction="out" type="aa{ss}" name="log_pages"/>
</method>
<method name="get_all_log_pages">
<arg direction="in" type="b" name="detailed"/>
<arg direction="out" type="s" name="log_pages_json"/>
</method>
<signal name="log_pages_changed">
<arg direction="out" type="s" name="transport"/>
<arg direction="out" type="s" name="traddr"/>
<arg direction="out" type="s" name="trsvcid"/>
<arg direction="out" type="s" name="host_traddr"/>
<arg direction="out" type="s" name="host_iface"/>
<arg direction="out" type="s" name="subsysnqn"/>
<arg direction="out" type="s" name="device"/>
</signal>
<signal name="dc_removed"></signal>
</interface>
</node>

554
staslib/stas.py Normal file
View file

@ -0,0 +1,554 @@
# Copyright (c) 2021, 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>
#
'''Library for staf/stac. You will find here common code for stafd and stacd
including the Abstract Base Classes (ABC) for Controllers and Services'''
import os
import sys
import abc
import signal
import pickle
import logging
import dasbus.connection
from gi.repository import Gio, GLib
from systemd.daemon import notify as sd_notify
from staslib import conf, defs, gutil, log, trid
try:
# Python 3.9 or later
# This is the preferred way, but may not be available before Python 3.9
from importlib.resources import files
except ImportError:
try:
# Pre Python 3.9 backport of importlib.resources (if installed)
from importlib_resources import files
except ImportError:
# Less efficient, but avalable on older versions of Python
import pkg_resources
def load_idl(idl_fname):
'''@brief Load D-Bus Interface Description Language File'''
try:
return pkg_resources.resource_string('staslib', idl_fname).decode()
except (FileNotFoundError, AttributeError):
pass
return ''
else:
def load_idl(idl_fname):
'''@brief Load D-Bus Interface Description Language File'''
try:
return files('staslib').joinpath(idl_fname).read_text() # pylint: disable=unspecified-encoding
except FileNotFoundError:
pass
return ''
else:
def load_idl(idl_fname):
'''@brief Load D-Bus Interface Description Language File'''
try:
return files('staslib').joinpath(idl_fname).read_text() # pylint: disable=unspecified-encoding
except FileNotFoundError:
pass
return ''
# ******************************************************************************
def check_if_allowed_to_continue():
'''@brief Let's perform some basic checks before going too far. There are
a few pre-requisites that need to be met before this program
is allowed to proceed:
1) The program needs to have root privileges
2) The nvme-tcp kernel module must be loaded
@return This function will only return if all conditions listed above
are met. Otherwise the program exits.
'''
# 1) Check root privileges
if os.geteuid() != 0:
sys.exit(f'Permission denied. You need root privileges to run {defs.PROG_NAME}.')
# 2) Check that nvme-tcp kernel module is running
if not os.path.exists('/dev/nvme-fabrics'):
# There's no point going any further if the kernel module hasn't been loaded
sys.exit('Fatal error: missing nvme-tcp kernel module')
# ******************************************************************************
def tid_from_dlpe(dlpe, host_traddr, host_iface):
'''@brief Take a Discovery Log Page Entry and return a Controller ID as a dict.'''
cid = {
'transport': dlpe['trtype'],
'traddr': dlpe['traddr'],
'trsvcid': dlpe['trsvcid'],
'host-traddr': host_traddr,
'host-iface': host_iface,
'subsysnqn': dlpe['subnqn'],
}
return trid.TID(cid)
# ******************************************************************************
def _excluded(excluded_ctrl_list, controller: dict):
'''@brief Check if @controller is excluded.'''
for excluded_ctrl in excluded_ctrl_list:
test_results = [val == controller.get(key, None) for key, val in excluded_ctrl.items()]
if all(test_results):
return True
return False
# ******************************************************************************
def remove_excluded(controllers: list):
'''@brief Remove excluded controllers from the list of controllers.
@param controllers: List of TIDs
'''
excluded_ctrl_list = conf.SvcConf().get_excluded()
if excluded_ctrl_list:
logging.debug('remove_excluded() - excluded_ctrl_list = %s', excluded_ctrl_list)
controllers = [
controller for controller in controllers if not _excluded(excluded_ctrl_list, controller.as_dict())
]
return controllers
# ******************************************************************************
class ControllerABC(abc.ABC):
'''@brief Base class used to manage the connection to a controller.'''
CONNECT_RETRY_PERIOD_SEC = 60
FAST_CONNECT_RETRY_PERIOD_SEC = 3
def __init__(self, tid: trid.TID, service, discovery_ctrl: bool = False):
self._tid = tid
self._serv = service # Refers to the parent service (either Staf or Stac)
self.set_level_from_tron(self._serv.tron)
self._cancellable = Gio.Cancellable()
self._connect_attempts = 0
self._retry_connect_tmr = gutil.GTimer(self.CONNECT_RETRY_PERIOD_SEC, self._on_try_to_connect)
self._discovery_ctrl = discovery_ctrl
self._try_to_connect_deferred = gutil.Deferred(self._try_to_connect)
self._try_to_connect_deferred.schedule()
def _release_resources(self):
# Remove pending deferred from main loop
if self._try_to_connect_deferred:
self._try_to_connect_deferred.cancel()
if self._retry_connect_tmr is not None:
self._retry_connect_tmr.kill()
if self._alive():
self._cancellable.cancel()
self._tid = None
self._serv = None
self._cancellable = None
self._retry_connect_tmr = None
self._try_to_connect_deferred = None
@property
def id(self) -> str:
'''@brief Return the Transport ID as a printable string'''
return str(self.tid)
@property
def tid(self):
'''@brief Return the Transport ID object'''
return self._tid
def controller_id_dict(self) -> dict:
'''@brief return the controller ID as a dict.'''
return {k: str(v) for k, v in self.tid.as_dict().items()}
def details(self) -> dict:
'''@brief return detailed debug info about this controller'''
return self.info()
def info(self) -> dict:
'''@brief Get the controller info for this object'''
info = self.controller_id_dict()
info['connect attempts'] = str(self._connect_attempts)
info['retry connect timer'] = str(self._retry_connect_tmr)
return info
def cancel(self):
'''@brief Used to cancel pending operations.'''
if self._alive():
logging.debug('ControllerABC.cancel() - %s', self.id)
self._cancellable.cancel()
def kill(self):
'''@brief Used to release all resources associated with this object.'''
logging.debug('ControllerABC.kill() - %s', self.id)
self._release_resources()
def _alive(self):
'''There may be race condition where a queued event gets processed
after the object is no longer configured (i.e. alive). This method
can be used by callback functions to make sure the object is still
alive before processing further.
'''
return self._cancellable and not self._cancellable.is_cancelled()
def _on_try_to_connect(self):
if self._alive():
self._try_to_connect_deferred.schedule()
return GLib.SOURCE_REMOVE
def _should_try_to_reconnect(self): # pylint: disable=no-self-use
return True
def _try_to_connect(self):
if not self._alive():
return GLib.SOURCE_REMOVE
# This is a deferred function call. Make sure
# the source of the deferred is still good.
source = GLib.main_current_source()
if source and source.is_destroyed():
return GLib.SOURCE_REMOVE
self._connect_attempts += 1
self._do_connect()
return GLib.SOURCE_REMOVE
@abc.abstractmethod
def set_level_from_tron(self, tron):
'''Set log level based on TRON'''
@abc.abstractmethod
def _do_connect(self):
'''Perform connection'''
@abc.abstractmethod
def _on_aen(self, aen: int):
'''Event handler when an AEN is received'''
@abc.abstractmethod
def _on_nvme_event(self, nvme_event):
'''Event handler when an nvme_event is received'''
@abc.abstractmethod
def _on_ctrl_removed(self, udev_obj):
'''Called when the associated nvme device (/dev/nvmeX) is removed
from the system by the kernel.
'''
@abc.abstractmethod
def _find_existing_connection(self):
'''Check if there is an existing connection that matches this Controller's TID'''
@abc.abstractmethod
def all_ops_completed(self) -> bool:
'''@brief Returns True if all operations have completed. False otherwise.'''
@abc.abstractmethod
def connected(self):
'''@brief Return whether a connection is established'''
@abc.abstractmethod
def disconnect(self, disconnected_cb, keep_connection):
'''@brief Issue an asynchronous disconnect command to a Controller.
Once the async command has completed, the callback 'disconnected_cb'
will be invoked. If a controller is already disconnected, then the
callback will be added to the main loop's next idle slot to be executed
ASAP.
'''
@abc.abstractmethod
def reload_hdlr(self):
'''@brief This is called when a "reload" signal is received.'''
# ******************************************************************************
class ServiceABC(abc.ABC): # pylint: disable=too-many-instance-attributes
'''@brief Base class used to manage a STorage Appliance Service'''
CONF_STABILITY_SOAK_TIME_SEC = 1.5
def __init__(self, args, default_conf, reload_hdlr):
service_conf = conf.SvcConf(default_conf=default_conf)
service_conf.set_conf_file(args.conf_file) # reload configuration
self._tron = args.tron or service_conf.tron
log.set_level_from_tron(self._tron)
self._lkc_file = os.path.join(
os.environ.get('RUNTIME_DIRECTORY', os.path.join('/run', defs.PROG_NAME)), 'last-known-config.pickle'
)
self._loop = GLib.MainLoop()
self._cancellable = Gio.Cancellable()
self._resolver = gutil.NameResolver()
self._controllers = self._load_last_known_config()
self._dbus_iface = None
self._cfg_soak_tmr = gutil.GTimer(self.CONF_STABILITY_SOAK_TIME_SEC, self._on_config_ctrls)
self._sysbus = dasbus.connection.SystemMessageBus()
GLib.unix_signal_add(GLib.PRIORITY_HIGH, signal.SIGINT, self._stop_hdlr) # CTRL-C
GLib.unix_signal_add(GLib.PRIORITY_HIGH, signal.SIGTERM, self._stop_hdlr) # systemctl stop stafd
GLib.unix_signal_add(GLib.PRIORITY_HIGH, signal.SIGHUP, reload_hdlr) # systemctl reload stafd
nvme_options = conf.NvmeOptions()
if not nvme_options.host_iface_supp or not nvme_options.discovery_supp:
logging.warning(
'Kernel does not appear to support all the options needed to run this program. Consider updating to a later kernel version.'
)
# We don't want to apply configuration changes right away.
# Often, multiple changes will occur in a short amount of time (sub-second).
# We want to wait until there are no more changes before applying them
# to the system. The following timer acts as a "soak period". Changes
# will be applied by calling self._on_config_ctrls() at the end of
# the soak period.
self._cfg_soak_tmr.start()
def _release_resources(self):
logging.debug('ServiceABC._release_resources()')
if self._alive():
self._cancellable.cancel()
if self._cfg_soak_tmr is not None:
self._cfg_soak_tmr.kill()
self._controllers.clear()
if self._sysbus:
self._sysbus.disconnect()
self._cfg_soak_tmr = None
self._cancellable = None
self._resolver = None
self._lkc_file = None
self._sysbus = None
def _config_dbus(self, iface_obj, bus_name: str, obj_name: str):
self._dbus_iface = iface_obj
self._sysbus.publish_object(obj_name, iface_obj)
self._sysbus.register_service(bus_name)
@property
def tron(self):
'''@brief Get Trace ON property'''
return self._tron
@tron.setter
def tron(self, value):
'''@brief Set Trace ON property'''
self._tron = value
log.set_level_from_tron(self._tron)
for controller in self._controllers.values():
controller.set_level_from_tron(self._tron)
def run(self):
'''@brief Start the main loop execution'''
try:
self._loop.run()
except Exception as ex: # pylint: disable=broad-except
logging.critical('exception: %s', ex)
self._loop = None
def info(self) -> dict:
'''@brief Get the status info for this object (used for debug)'''
nvme_options = conf.NvmeOptions()
info = conf.SysConf().as_dict()
info['last known config file'] = self._lkc_file
info['config soak timer'] = str(self._cfg_soak_tmr)
info['kernel support.TP8013'] = str(nvme_options.discovery_supp)
info['kernel support.host_iface'] = str(nvme_options.host_iface_supp)
return info
def get_controllers(self) -> dict:
'''@brief return the list of controller objects'''
return self._controllers.values()
def get_controller(
self, transport: str, traddr: str, trsvcid: str, host_traddr: str, host_iface: str, subsysnqn: str
): # pylint: disable=too-many-arguments
'''@brief get the specified controller object from the list of controllers'''
cid = {
'transport': transport,
'traddr': traddr,
'trsvcid': trsvcid,
'host-traddr': host_traddr,
'host-iface': host_iface,
'subsysnqn': subsysnqn,
}
return self._controllers.get(trid.TID(cid))
def _remove_ctrl_from_dict(self, controller, shutdown=False):
tid_to_pop = controller.tid
if not tid_to_pop:
# Being paranoid. This should not happen, but let's say the
# controller object has been purged, but it is somehow still
# listed in self._controllers.
for tid, _controller in self._controllers.items():
if _controller is controller:
tid_to_pop = tid
break
if tid_to_pop:
logging.debug('ServiceABC._remove_ctrl_from_dict()- %s | %s', tid_to_pop, controller.device)
popped = self._controllers.pop(tid_to_pop, None)
if not shutdown and popped is not None and self._cfg_soak_tmr:
self._cfg_soak_tmr.start()
else:
logging.debug('ServiceABC._remove_ctrl_from_dict()- already removed')
def remove_controller(self, controller, success): # pylint: disable=unused-argument
'''@brief remove the specified controller object from the list of controllers
@param controller: the controller object
@param success: whether the disconnect was successful'''
logging.debug('ServiceABC.remove_controller()')
if isinstance(controller, ControllerABC):
self._remove_ctrl_from_dict(controller)
controller.kill()
def _alive(self):
'''It's a good idea to check that this object hasn't been
cancelled (i.e. is still alive) when entering a callback function.
Callback functrions can be invoked after, for example, a process has
been signalled to stop or restart, in which case it makes no sense to
proceed with the callback.
'''
return self._cancellable and not self._cancellable.is_cancelled()
def _cancel(self):
logging.debug('ServiceABC._cancel()')
if self._alive():
self._cancellable.cancel()
for controller in self._controllers.values():
controller.cancel()
def _stop_hdlr(self):
logging.debug('ServiceABC._stop_hdlr()')
sd_notify('STOPPING=1')
self._cancel() # Cancel pending operations
self._dump_last_known_config(self._controllers)
if len(self._controllers) == 0:
GLib.idle_add(self._exit)
else:
self._disconnect_all()
return GLib.SOURCE_REMOVE
def _on_final_disconnect(self, controller, success):
'''Callback invoked after a controller is disconnected.
THIS IS USED DURING PROCESS SHUTDOWN TO WAIT FOR ALL CONTROLLERS TO BE
DISCONNECTED BEFORE EXITING THE PROGRAM. ONLY CALL ON SHUTDOWN!
@param controller: the controller object
@param success: whether the disconnect operation was successful
'''
logging.debug(
'ServiceABC._on_final_disconnect() - %s | %s: disconnect %s',
controller.id,
controller.device,
'succeeded' if success else 'failed',
)
self._remove_ctrl_from_dict(controller, True)
controller.kill()
# When all controllers have disconnected, we can finish the clean up
if len(self._controllers) == 0:
# Defer exit to the next main loop's idle period.
GLib.idle_add(self._exit)
def _exit(self):
logging.debug('ServiceABC._exit()')
self._release_resources()
self._loop.quit()
def _on_config_ctrls(self, *_user_data):
if self._alive():
self._config_ctrls()
return GLib.SOURCE_REMOVE
def _config_ctrls(self):
'''@brief Start controllers configuration.'''
# The configuration file may contain controllers and/or excluded
# controllers with traddr specified as hostname instead of IP address.
# Because of this, we need to remove those excluded elements before
# running name resolution. And we will need to remove excluded
# elements after name resolution is complete (i.e. in the calback
# function _config_ctrls_finish)
logging.debug('ServiceABC._config_ctrls()')
configured_controllers = [trid.TID(cid) for cid in conf.SvcConf().get_controllers()]
configured_controllers = remove_excluded(configured_controllers)
self._resolver.resolve_ctrl_async(self._cancellable, configured_controllers, self._config_ctrls_finish)
def _read_lkc(self):
'''@brief Read Last Known Config from file'''
try:
with open(self._lkc_file, 'rb') as file:
return pickle.load(file)
except (FileNotFoundError, AttributeError, EOFError):
return None
def _write_lkc(self, config):
'''@brief Write Last Known Config to file, and if config is empty
make sure the file is emptied.'''
try:
# Note that if config is empty we still
# want to open/close the file to empty it.
with open(self._lkc_file, 'wb') as file:
if config:
pickle.dump(config, file)
except FileNotFoundError as ex:
logging.error('Unable to save last known config: %s', ex)
@abc.abstractmethod
def _disconnect_all(self):
'''Tell all controller objects to disconnect'''
@abc.abstractmethod
def _keep_connections_on_exit(self):
'''@brief Determine whether connections should remain when the
process exits.
NOTE) This is the base class method used to define the interface.
It must be overloaded by a child class.
'''
@abc.abstractmethod
def _config_ctrls_finish(self, configured_ctrl_list):
'''@brief Finish controllers configuration after hostnames (if any)
have been resolved.
Configuring controllers must be done asynchronously in 2 steps.
In the first step, host names get resolved to find their IP addresses.
Name resolution can take a while, especially when an external name
resolution server is used. Once that step completed, the callback
method _config_ctrls_finish() (i.e. this method), gets invoked to
complete the controller configuration.
NOTE) This is the base class method used to define the interface.
It must be overloaded by a child class.
'''
@abc.abstractmethod
def _load_last_known_config(self):
'''Load last known config from file (if any)'''
@abc.abstractmethod
def _dump_last_known_config(self, controllers):
'''Save last known config to file'''

139
staslib/timeparse.py Normal file
View file

@ -0,0 +1,139 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
'''
This module was borrowed and modified from: https://github.com/wroberts/pytimeparse
timeparse.py
(c) Will Roberts <wildwilhelm@gmail.com> 1 February, 2014
Implements a single function, `timeparse`, which can parse various
kinds of time expressions.
'''
# MIT LICENSE
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import re
SIGN = r'(?P<sign>[+|-])?'
DAYS = r'(?P<days>[\d.]+)\s*(?:d|dys?|days?)'
HOURS = r'(?P<hours>[\d.]+)\s*(?:h|hrs?|hours?)'
MINS = r'(?P<mins>[\d.]+)\s*(?:m|(mins?)|(minutes?))'
SECS = r'(?P<secs>[\d.]+)\s*(?:s|secs?|seconds?)'
SEPARATORS = r'[,/]'
SECCLOCK = r':(?P<secs>\d{2}(?:\.\d+)?)'
MINCLOCK = r'(?P<mins>\d{1,2}):(?P<secs>\d{2}(?:\.\d+)?)'
HOURCLOCK = r'(?P<hours>\d+):(?P<mins>\d{2}):(?P<secs>\d{2}(?:\.\d+)?)'
DAYCLOCK = r'(?P<days>\d+):(?P<hours>\d{2}):(?P<mins>\d{2}):(?P<secs>\d{2}(?:\.\d+)?)'
def _opt(string):
return f'(?:{string})?'
def _optsep(string):
return fr'(?:{string}\s*(?:{SEPARATORS}\s*)?)?'
TIMEFORMATS = [
fr'{_optsep(DAYS)}\s*{_optsep(HOURS)}\s*{_optsep(MINS)}\s*{_opt(SECS)}',
f'{MINCLOCK}',
fr'{_optsep(DAYS)}\s*{HOURCLOCK}',
f'{DAYCLOCK}',
f'{SECCLOCK}',
]
COMPILED_SIGN = re.compile(r'\s*' + SIGN + r'\s*(?P<unsigned>.*)$')
COMPILED_TIMEFORMATS = [re.compile(r'\s*' + timefmt + r'\s*$', re.I) for timefmt in TIMEFORMATS]
MULTIPLIERS = {
'days': 60 * 60 * 24,
'hours': 60 * 60,
'mins': 60,
'secs': 1,
}
def timeparse(sval):
'''
Parse a time expression, returning it as a number of seconds. If
possible, the return value will be an `int`; if this is not
possible, the return will be a `float`. Returns `None` if a time
expression cannot be parsed from the given string.
Arguments:
- `sval`: the string value to parse
>>> timeparse('1:24')
84
>>> timeparse(':22')
22
>>> timeparse('1 minute, 24 secs')
84
>>> timeparse('1m24s')
84
>>> timeparse('1.2 minutes')
72
>>> timeparse('1.2 seconds')
1.2
Time expressions can be signed.
>>> timeparse('- 1 minute')
-60
>>> timeparse('+ 1 minute')
60
'''
try:
return float(sval)
except TypeError:
pass
except ValueError:
match = COMPILED_SIGN.match(sval)
sign = -1 if match.groupdict()['sign'] == '-' else 1
sval = match.groupdict()['unsigned']
for timefmt in COMPILED_TIMEFORMATS:
match = timefmt.match(sval)
if match and match.group(0).strip():
mdict = match.groupdict()
# if all of the fields are integer numbers
if all(v.isdigit() for v in list(mdict.values()) if v):
return sign * sum((MULTIPLIERS[k] * int(v, 10) for (k, v) in list(mdict.items()) if v is not None))
# if SECS is an integer number
if 'secs' not in mdict or mdict['secs'] is None or mdict['secs'].isdigit():
# we will return an integer
return sign * int(
sum(
(
MULTIPLIERS[k] * float(v)
for (k, v) in list(mdict.items())
if k != 'secs' and v is not None
)
)
) + (int(mdict['secs'], 10) if mdict['secs'] else 0)
# SECS is a float, we will return a float
return sign * sum((MULTIPLIERS[k] * float(v) for (k, v) in list(mdict.items()) if v is not None))
return None

137
staslib/trid.py Normal file
View file

@ -0,0 +1,137 @@
# 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>
#
'''This module defines the Transport Identifier Object, which is used
throughout nvme-stas to uniquely identify a Controller'''
import hashlib
from staslib import conf
class TID: # pylint: disable=too-many-instance-attributes
'''Transport Identifier'''
RDMA_IP_PORT = '4420'
DISC_IP_PORT = '8009'
def __init__(self, cid: dict):
'''@param cid: Controller Identifier. A dictionary with the following
contents.
{
# Transport parameters
'transport': str, # [mandatory]
'traddr': str, # [mandatory]
'subsysnqn': str, # [mandatory]
'trsvcid': str, # [optional]
'host-traddr': str, # [optional]
'host-iface': str, # [optional]
# Connection parameters
'dhchap-ctrl-secret': str, # [optional]
'hdr-digest': str, # [optional]
'data-digest': str, # [optional]
'nr-io-queues': str, # [optional]
'nr-write-queues': str, # [optional]
'nr-poll-queues': str, # [optional]
'queue-size': str, # [optional]
'kato': str, # [optional]
'reconnect-delay': str, # [optional]
'ctrl-loss-tmo': str, # [optional]
'disable-sqflow': str, # [optional]
}
'''
self._cfg = {
k: v
for k, v in cid.items()
if k not in ('transport', 'traddr', 'subsysnqn', 'trsvcid', 'host-traddr', 'host-iface')
}
self._transport = cid.get('transport', '')
self._traddr = cid.get('traddr', '')
self._trsvcid = ''
if self._transport in ('tcp', 'rdma'):
trsvcid = cid.get('trsvcid', None)
self._trsvcid = (
trsvcid if trsvcid else (TID.RDMA_IP_PORT if self._transport == 'rdma' else TID.DISC_IP_PORT)
)
self._host_traddr = cid.get('host-traddr', '')
self._host_iface = '' if conf.SvcConf().ignore_iface else cid.get('host-iface', '')
self._subsysnqn = cid.get('subsysnqn', '')
self._shortkey = (self._transport, self._traddr, self._trsvcid, self._subsysnqn, self._host_traddr)
self._key = (self._transport, self._traddr, self._trsvcid, self._subsysnqn, self._host_traddr, self._host_iface)
self._hash = int.from_bytes(
hashlib.md5(''.join(self._key).encode('utf-8')).digest(), 'big'
) # We need a consistent hash between restarts
self._id = f'({self._transport}, {self._traddr}, {self._trsvcid}{", " + self._subsysnqn if self._subsysnqn else ""}{", " + self._host_iface if self._host_iface else ""}{", " + self._host_traddr if self._host_traddr else ""})' # pylint: disable=line-too-long
@property
def transport(self): # pylint: disable=missing-function-docstring
return self._transport
@property
def traddr(self): # pylint: disable=missing-function-docstring
return self._traddr
@property
def trsvcid(self): # pylint: disable=missing-function-docstring
return self._trsvcid
@property
def host_traddr(self): # pylint: disable=missing-function-docstring
return self._host_traddr
@property
def host_iface(self): # pylint: disable=missing-function-docstring
return self._host_iface
@property
def subsysnqn(self): # pylint: disable=missing-function-docstring
return self._subsysnqn
@property
def cfg(self): # pylint: disable=missing-function-docstring
return self._cfg
def as_dict(self):
'''Return object members as a dictionary'''
data = {
'transport': self.transport,
'traddr': self.traddr,
'subsysnqn': self.subsysnqn,
'trsvcid': self.trsvcid,
'host-traddr': self.host_traddr,
'host-iface': self.host_iface,
}
data.update(self._cfg)
return data
def __str__(self):
return self._id
def __repr__(self):
return self._id
def __eq__(self, other):
if not isinstance(other, self.__class__):
return False
if self._host_iface and other._host_iface:
return self._key == other._key
return self._shortkey == other._shortkey
def __ne__(self, other):
if not isinstance(other, self.__class__):
return True
if self._host_iface and other._host_iface:
return self._key != other._key
return self._shortkey != other._shortkey
def __hash__(self):
return self._hash

334
staslib/udev.py Normal file
View file

@ -0,0 +1,334 @@
# 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>
#
'''This module provides functions to access nvme devices using the pyudev module'''
import os
import time
import logging
import pyudev
from gi.repository import GLib
from staslib import defs, iputil, trid
# ******************************************************************************
class Udev:
'''@brief Udev event monitor. Provide a way to register for udev events.
WARNING: THE singleton.Singleton PATTERN CANNOT BE USED WITH THIS CLASS.
IT INTERFERES WITH THE pyudev INTERNALS, WHICH CAUSES OBJECT CLEAN UP TO FAIL.
'''
def __init__(self):
self._log_event_soak_time = 0
self._log_event_count = 0
self._device_event_registry = dict()
self._action_event_registry = dict()
self._context = pyudev.Context()
self._monitor = pyudev.Monitor.from_netlink(self._context)
self._monitor.filter_by(subsystem='nvme')
self._event_source = GLib.io_add_watch(
self._monitor.fileno(),
GLib.PRIORITY_HIGH,
GLib.IO_IN,
self._process_udev_event,
)
self._monitor.start()
def release_resources(self):
'''Release all resources used by this object'''
if self._event_source is not None:
GLib.source_remove(self._event_source)
if self._monitor is not None:
self._monitor.remove_filter()
self._event_source = None
self._monitor = None
self._context = None
self._device_event_registry = None
self._action_event_registry = None
def get_nvme_device(self, sys_name):
'''@brief Get the udev device object associated with an nvme device.
@param sys_name: The device system name (e.g. 'nvme1')
@return A pyudev.device._device.Device object
'''
device_node = os.path.join('/dev', sys_name)
try:
return pyudev.Devices.from_device_file(self._context, device_node)
except pyudev.DeviceNotFoundByFileError as ex:
logging.error("Udev.get_nvme_device() - Error: %s", ex)
return None
def is_action_cback_registered(self, action: str, user_cback):
'''Returns True if @user_cback is registered for @action. False otherwise.
@param action: one of 'add', 'remove', 'change'.
@param user_cback: A callback function with this signature: cback(udev_obj)
'''
return user_cback in self._action_event_registry.get(action, set())
def register_for_action_events(self, action: str, user_cback):
'''@brief Register a callback function to be called when udev events
for a specific action are received.
@param action: one of 'add', 'remove', 'change'.
'''
self._action_event_registry.setdefault(action, set()).add(user_cback)
def unregister_for_action_events(self, action: str, user_cback):
'''@brief The opposite of register_for_action_events()'''
try:
self._action_event_registry.get(action, set()).remove(user_cback)
except KeyError: # Raise if user_cback already removed
pass
def register_for_device_events(self, sys_name: str, user_cback):
'''@brief Register a callback function to be called when udev events
are received for a specific nvme device.
@param sys_name: The device system name (e.g. 'nvme1')
'''
if sys_name:
self._device_event_registry[sys_name] = user_cback
def unregister_for_device_events(self, user_cback):
'''@brief The opposite of register_for_device_events()'''
entries = list(self._device_event_registry.items())
for sys_name, _user_cback in entries:
if user_cback == _user_cback:
self._device_event_registry.pop(sys_name, None)
break
def get_attributes(self, sys_name: str, attr_ids) -> dict:
'''@brief Get all the attributes associated with device @sys_name'''
attrs = {attr_id: '' for attr_id in attr_ids}
if sys_name and sys_name != 'nvme?':
udev = self.get_nvme_device(sys_name)
if udev is not None:
for attr_id in attr_ids:
try:
value = udev.attributes.asstring(attr_id).strip()
attrs[attr_id] = '' if value == '(efault)' else value
except Exception: # pylint: disable=broad-except
pass
return attrs
@staticmethod
def is_dc_device(device):
'''@brief check whether device refers to a Discovery Controller'''
subsysnqn = device.attributes.get('subsysnqn')
if subsysnqn is not None and subsysnqn.decode() == defs.WELL_KNOWN_DISC_NQN:
return True
# Note: Prior to 5.18 linux didn't expose the cntrltype through
# the sysfs. So, this may return None on older kernels.
cntrltype = device.attributes.get('cntrltype')
if cntrltype is not None and cntrltype.decode() == 'discovery':
return True
# Imply Discovery controller based on the absence of children.
# Discovery Controllers have no children devices
if len(list(device.children)) == 0:
return True
return False
@staticmethod
def is_ioc_device(device):
'''@brief check whether device refers to an I/O Controller'''
# Note: Prior to 5.18 linux didn't expose the cntrltype through
# the sysfs. So, this may return None on older kernels.
cntrltype = device.attributes.get('cntrltype')
if cntrltype is not None and cntrltype.decode() == 'io':
return True
# Imply I/O controller based on the presence of children.
# I/O Controllers have children devices
if len(list(device.children)) != 0:
return True
return False
def find_nvme_dc_device(self, tid):
'''@brief Find the nvme device associated with the specified
Discovery Controller.
@return The device if a match is found, None otherwise.
'''
for device in self._context.list_devices(
subsystem='nvme', NVME_TRADDR=tid.traddr, NVME_TRSVCID=tid.trsvcid, NVME_TRTYPE=tid.transport
):
if not self.is_dc_device(device):
continue
if self.get_tid(device) != tid:
continue
return device
return None
def find_nvme_ioc_device(self, tid):
'''@brief Find the nvme device associated with the specified
I/O Controller.
@return The device if a match is found, None otherwise.
'''
for device in self._context.list_devices(
subsystem='nvme', NVME_TRADDR=tid.traddr, NVME_TRSVCID=tid.trsvcid, NVME_TRTYPE=tid.transport
):
if not self.is_ioc_device(device):
continue
if self.get_tid(device) != tid:
continue
return device
return None
def get_nvme_ioc_tids(self, transports):
'''@brief Find all the I/O controller nvme devices in the system.
@return A list of pyudev.device._device.Device objects
'''
tids = []
for device in self._context.list_devices(subsystem='nvme'):
if device.properties.get('NVME_TRTYPE', '') not in transports:
continue
if not self.is_ioc_device(device):
continue
tids.append(self.get_tid(device))
return tids
def _process_udev_event(self, event_source, condition): # pylint: disable=unused-argument
if condition == GLib.IO_IN:
event_count = 0
while True:
try:
device = self._monitor.poll(timeout=0)
except EnvironmentError as ex:
device = None
# This event seems to happen in bursts. So, let's suppress
# logging for 2 seconds to avoid filling the syslog.
self._log_event_count += 1
now = time.time()
if now > self._log_event_soak_time:
logging.debug('Udev._process_udev_event() - %s [%s]', ex, self._log_event_count)
self._log_event_soak_time = now + 2
self._log_event_count = 0
if device is None:
break
event_count += 1
self._device_event(device, event_count)
return GLib.SOURCE_CONTINUE
@staticmethod
def __cback_names(action_cbacks, device_cback):
names = []
for cback in action_cbacks:
names.append(cback.__name__ + '()')
if device_cback:
names.append(device_cback.__name__ + '()')
return names
def _device_event(self, device, event_count):
action_cbacks = self._action_event_registry.get(device.action, set())
device_cback = self._device_event_registry.get(device.sys_name, None)
logging.debug(
'Udev._device_event() - %-8s %-6s %-8s %s',
f'{device.sys_name}:',
device.action,
f'{event_count:2}:{device.sequence_number}',
self.__cback_names(action_cbacks, device_cback),
)
for action_cback in action_cbacks:
GLib.idle_add(action_cback, device)
if device_cback is not None:
GLib.idle_add(device_cback, device)
@staticmethod
def _get_property(device, prop, default=''):
prop = device.properties.get(prop, default)
return '' if prop.lower() == 'none' else prop
@staticmethod
def _get_attribute(device, attr_id, default=''):
try:
attr = device.attributes.asstring(attr_id).strip()
except Exception: # pylint: disable=broad-except
attr = default
return '' if attr.lower() == 'none' else attr
@staticmethod
def get_key_from_attr(device, attr, key, delim=','):
'''Get attribute specified by attr, which is composed of key=value pairs.
Then return the value associated with key.
@param device: The Device object
@param attr: The device's attribute to get
@param key: The key to look for in the attribute
@param delim: Delimiter used between key=value pairs.
@example:
"address" attribute contains "trtype=tcp,traddr=10.10.1.100,trsvcid=4420,host_traddr=10.10.1.50"
'''
attr_str = Udev._get_attribute(device, attr)
if not attr_str:
return ''
if key[-1] != '=':
key += '='
start = attr_str.find(key)
if start < 0:
return ''
start += len(key)
end = attr_str.find(delim, start)
if end < 0:
return attr_str[start:]
return attr_str[start:end]
@staticmethod
def _get_host_iface(device):
host_iface = Udev._get_property(device, 'NVME_HOST_IFACE')
if not host_iface:
# We'll try to find the interface from the source address on
# the connection. Only available if kernel exposes the source
# address (src_addr) in the "address" attribute.
src_addr = Udev.get_key_from_attr(device, 'address', 'src_addr=')
host_iface = iputil.get_interface(src_addr)
return host_iface
@staticmethod
def get_tid(device):
'''@brief return the Transport ID associated with a udev device'''
cid = {
'transport': Udev._get_property(device, 'NVME_TRTYPE'),
'traddr': Udev._get_property(device, 'NVME_TRADDR'),
'trsvcid': Udev._get_property(device, 'NVME_TRSVCID'),
'host-traddr': Udev._get_property(device, 'NVME_HOST_TRADDR'),
'host-iface': Udev._get_host_iface(device),
'subsysnqn': Udev._get_attribute(device, 'subsysnqn'),
}
return trid.TID(cid)
UDEV = Udev() # Singleton
def shutdown():
'''Destroy the UDEV singleton'''
global UDEV # pylint: disable=global-statement,global-variable-not-assigned
UDEV.release_resources()
del UDEV

64
staslib/version.py Normal file
View file

@ -0,0 +1,64 @@
# Copyright (c) 2021, 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>
#
''' distutils (and hence LooseVersion) is being deprecated. None of the
suggested replacements (e.g. from pkg_resources import parse_version) quite
work with Linux kernel versions the way LooseVersion does.
It was suggested to simply lift the LooseVersion code and vendor it in,
which is what this module is about.
'''
import re
class KernelVersion:
'''Code loosely lifted from distutils's LooseVersion'''
component_re = re.compile(r'(\d+ | [a-z]+ | \.)', re.VERBOSE)
def __init__(self, string: str):
self.string = string
self.version = self.__parse(string)
def __str__(self):
return self.string
def __repr__(self):
return f'KernelVersion ("{self}")'
def __eq__(self, other):
return self.version == self.__version(other)
def __lt__(self, other):
return self.version < self.__version(other)
def __le__(self, other):
return self.version <= self.__version(other)
def __gt__(self, other):
return self.version > self.__version(other)
def __ge__(self, other):
return self.version >= self.__version(other)
@staticmethod
def __version(obj):
return obj.version if isinstance(obj, KernelVersion) else KernelVersion.__parse(obj)
@staticmethod
def __parse(string):
components = []
for item in KernelVersion.component_re.split(string):
if item and item != '.':
try:
components.append(int(item))
except ValueError:
pass
return components