summaryrefslogtreecommitdiffstats
path: root/staslib/conf.py
blob: 44976985713f13a5e8dd3358e900255dc2ff078e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
# Copyright (c) 2022, Dell Inc. or its subsidiaries.  All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# See the LICENSE file for details.
#
# This file is part of NVMe STorage Appliance Services (nvme-stas).
#
# Authors: Martin Belanger <Martin.Belanger@dell.com>
#
'''nvme-stas configuration module'''

import re
import os
import sys
import logging
import functools
import configparser
from urllib.parse import urlparse
from staslib import defs, iputil, nbft, singleton, timeparse

__TOKEN_RE = re.compile(r'\s*;\s*')
__OPTION_RE = re.compile(r'\s*=\s*')


class InvalidOption(Exception):
    '''Exception raised when an invalid option value is detected'''


def _parse_controller(controller):
    '''@brief Parse a "controller" entry. Controller entries are strings
           composed of several configuration parameters delimited by
           semi-colons. Each configuration parameter is specified as a
           "key=value" pair.
    @return A dictionary of key-value pairs.
    '''
    options = dict()
    tokens = __TOKEN_RE.split(controller)
    for token in tokens:
        if token:
            try:
                option, val = __OPTION_RE.split(token)
                options[option.strip()] = val.strip()
            except ValueError:
                pass

    return options


def _parse_single_val(text):
    if isinstance(text, str):
        return text
    if not isinstance(text, list) or len(text) == 0:
        return None

    return text[-1]


def _parse_list(text):
    return text if isinstance(text, list) else [text]


def _to_int(text):
    try:
        return int(_parse_single_val(text))
    except (ValueError, TypeError):
        raise InvalidOption  # pylint: disable=raise-missing-from


def _to_bool(text, positive='true'):
    return _parse_single_val(text).lower() == positive


def _to_ncc(text):
    value = _to_int(text)
    if value == 1:  # 1 is invalid. A minimum of 2 is required (with the exception of 0, which is valid).
        value = 2
    return value


def _to_ip_family(text):
    return tuple((4 if text == 'ipv4' else 6 for text in _parse_single_val(text).split('+')))


# ******************************************************************************
class OrderedMultisetDict(dict):
    '''This class is used to change the behavior of configparser.ConfigParser
    and allow multiple configuration parameters with the same key. The
    result is a list of values, where values are sorted by the order they
    appear in the file.
    '''

    def __setitem__(self, key, value):
        if key in self and isinstance(value, list):
            self[key].extend(value)
        else:
            super().__setitem__(key, value)

    def __getitem__(self, key):
        value = super().__getitem__(key)

        if isinstance(value, str):
            return value.split('\n')

        return value


class SvcConf(metaclass=singleton.Singleton):  # pylint: disable=too-many-public-methods
    '''Read and cache configuration file.'''

    OPTION_CHECKER = {
        'Global': {
            'tron': {
                'convert': _to_bool,
                'default': False,
                'txt-chk': lambda text: _parse_single_val(text).lower() in ('false', 'true'),
            },
            'kato': {
                'convert': _to_int,
            },
            'pleo': {
                'convert': functools.partial(_to_bool, positive='enabled'),
                'default': True,
                'txt-chk': lambda text: _parse_single_val(text).lower() in ('disabled', 'enabled'),
            },
            'ip-family': {
                'convert': _to_ip_family,
                'default': (4, 6),
                'txt-chk': lambda text: _parse_single_val(text) in ('ipv4', 'ipv6', 'ipv4+ipv6', 'ipv6+ipv4'),
            },
            'queue-size': {
                'convert': _to_int,
                'rng-chk': lambda value: None if value in range(16, 1025) else range(16, 1025),
            },
            'hdr-digest': {
                'convert': _to_bool,
                'default': False,
                'txt-chk': lambda text: _parse_single_val(text).lower() in ('false', 'true'),
            },
            'data-digest': {
                'convert': _to_bool,
                'default': False,
                'txt-chk': lambda text: _parse_single_val(text).lower() in ('false', 'true'),
            },
            'ignore-iface': {
                'convert': _to_bool,
                'default': False,
                'txt-chk': lambda text: _parse_single_val(text).lower() in ('false', 'true'),
            },
            'nr-io-queues': {
                'convert': _to_int,
            },
            'ctrl-loss-tmo': {
                'convert': _to_int,
            },
            'disable-sqflow': {
                'convert': _to_bool,
                'txt-chk': lambda text: _parse_single_val(text).lower() in ('false', 'true'),
            },
            'nr-poll-queues': {
                'convert': _to_int,
            },
            'nr-write-queues': {
                'convert': _to_int,
            },
            'reconnect-delay': {
                'convert': _to_int,
            },
            ### BEGIN: LEGACY SECTION TO BE REMOVED ###
            'persistent-connections': {
                'convert': _to_bool,
                'default': False,
                'txt-chk': lambda text: _parse_single_val(text).lower() in ('false', 'true'),
            },
            ### END: LEGACY SECTION TO BE REMOVED ###
        },
        'Service Discovery': {
            'zeroconf': {
                'convert': functools.partial(_to_bool, positive='enabled'),
                'default': True,
                'txt-chk': lambda text: _parse_single_val(text).lower() in ('disabled', 'enabled'),
            },
        },
        'Discovery controller connection management': {
            'persistent-connections': {
                'convert': _to_bool,
                'default': True,
                'txt-chk': lambda text: _parse_single_val(text).lower() in ('false', 'true'),
            },
            'zeroconf-connections-persistence': {
                'convert': lambda text: timeparse.timeparse(_parse_single_val(text)),
                'default': timeparse.timeparse('72hours'),
            },
        },
        'I/O controller connection management': {
            'disconnect-scope': {
                'convert': _parse_single_val,
                'default': 'only-stas-connections',
                'txt-chk': lambda text: _parse_single_val(text)
                in ('only-stas-connections', 'all-connections-matching-disconnect-trtypes', 'no-disconnect'),
            },
            'disconnect-trtypes': {
                # Use set() to eliminate potential duplicates
                'convert': lambda text: set(_parse_single_val(text).split('+')),
                'default': [
                    'tcp',
                ],
                'lst-chk': ('tcp', 'rdma', 'fc'),
            },
            'connect-attempts-on-ncc': {
                'convert': _to_ncc,
                'default': 0,
            },
        },
        'Controllers': {
            'controller': {
                'convert': _parse_list,
                'default': [],
            },
            'exclude': {
                'convert': _parse_list,
                'default': [],
            },
            ### BEGIN: LEGACY SECTION TO BE REMOVED ###
            'blacklist': {
                'convert': _parse_list,
                'default': [],
            },
            ### END: LEGACY SECTION TO BE REMOVED ###
        },
    }

    def __init__(self, default_conf=None, conf_file='/dev/null'):
        self._config = None
        self._defaults = default_conf if default_conf else {}

        if self._defaults is not None and len(self._defaults) != 0:
            self._valid_conf = {}
            for section, option in self._defaults:
                self._valid_conf.setdefault(section, set()).add(option)
        else:
            self._valid_conf = None

        self._conf_file = conf_file
        self.reload()

    def reload(self):
        '''@brief Reload the configuration file.'''
        self._config = self._read_conf_file()

    @property
    def conf_file(self):
        '''Return the configuration file name'''
        return self._conf_file

    def set_conf_file(self, fname):
        '''Set the configuration file name and reload config'''
        self._conf_file = fname
        self.reload()

    def get_option(self, section, option, ignore_default=False):  # pylint: disable=too-many-locals
        '''Retrieve @option from @section, convert raw text to
        appropriate object type, and validate.'''
        try:
            checker = self.OPTION_CHECKER[section][option]
        except KeyError:
            logging.error('Requesting invalid section=%s and/or option=%s', section, option)
            raise

        default = checker.get('default', None)

        try:
            text = self._config.get(section=section, option=option)
        except (configparser.NoSectionError, configparser.NoOptionError, KeyError):
            return None if ignore_default else self._defaults.get((section, option), default)

        return self._check(text, section, option, default)

    tron = property(functools.partial(get_option, section='Global', option='tron'))
    kato = property(functools.partial(get_option, section='Global', option='kato'))
    ip_family = property(functools.partial(get_option, section='Global', option='ip-family'))
    queue_size = property(functools.partial(get_option, section='Global', option='queue-size'))
    hdr_digest = property(functools.partial(get_option, section='Global', option='hdr-digest'))
    data_digest = property(functools.partial(get_option, section='Global', option='data-digest'))
    ignore_iface = property(functools.partial(get_option, section='Global', option='ignore-iface'))
    pleo_enabled = property(functools.partial(get_option, section='Global', option='pleo'))
    nr_io_queues = property(functools.partial(get_option, section='Global', option='nr-io-queues'))
    ctrl_loss_tmo = property(functools.partial(get_option, section='Global', option='ctrl-loss-tmo'))
    disable_sqflow = property(functools.partial(get_option, section='Global', option='disable-sqflow'))
    nr_poll_queues = property(functools.partial(get_option, section='Global', option='nr-poll-queues'))
    nr_write_queues = property(functools.partial(get_option, section='Global', option='nr-write-queues'))
    reconnect_delay = property(functools.partial(get_option, section='Global', option='reconnect-delay'))

    zeroconf_enabled = property(functools.partial(get_option, section='Service Discovery', option='zeroconf'))

    zeroconf_persistence_sec = property(
        functools.partial(
            get_option, section='Discovery controller connection management', option='zeroconf-connections-persistence'
        )
    )

    disconnect_scope = property(
        functools.partial(get_option, section='I/O controller connection management', option='disconnect-scope')
    )
    disconnect_trtypes = property(
        functools.partial(get_option, section='I/O controller connection management', option='disconnect-trtypes')
    )
    connect_attempts_on_ncc = property(
        functools.partial(get_option, section='I/O controller connection management', option='connect-attempts-on-ncc')
    )

    @property
    def stypes(self):
        '''@brief Get the DNS-SD/mDNS service types.'''
        return ['_nvme-disc._tcp', '_nvme-disc._udp'] if self.zeroconf_enabled else list()

    @property
    def persistent_connections(self):
        '''@brief return the "persistent-connections" config parameter'''
        section = 'Discovery controller connection management'
        option = 'persistent-connections'

        value = self.get_option(section, option, ignore_default=True)
        legacy = self.get_option('Global', option, ignore_default=True)

        if value is None and legacy is None:
            return self._defaults.get((section, option), True)

        return value or legacy

    def get_controllers(self):
        '''@brief Return the list of controllers in the config file.
        Each controller is in the form of a dictionary as follows.
        Note that some of the keys are optional.
        {
            'transport':          [TRANSPORT],
            'traddr':             [TRADDR],
            'trsvcid':            [TRSVCID],
            'subsysnqn':          [NQN],
            'host-traddr':        [TRADDR],
            'host-iface':         [IFACE],
            'host-nqn':           [NQN],
            'dhchap-ctrl-secret': [KEY],
            'hdr-digest':         [BOOL]
            'data-digest':        [BOOL]
            'nr-io-queues':       [NUMBER]
            'nr-write-queues':    [NUMBER]
            'nr-poll-queues':     [NUMBER]
            'queue-size':         [SIZE]
            'kato':               [KATO]
            'reconnect-delay':    [SECONDS]
            'ctrl-loss-tmo':      [SECONDS]
            'disable-sqflow':     [BOOL]
        }
        '''
        controller_list = self.get_option('Controllers', 'controller')
        cids = [_parse_controller(controller) for controller in controller_list]
        for cid in cids:
            try:
                # replace 'nqn' key by 'subsysnqn', if present.
                cid['subsysnqn'] = cid.pop('nqn')
            except KeyError:
                pass

            # Verify values of the options used to overload the matching [Global] options
            for option in cid:
                if option in self.OPTION_CHECKER['Global']:
                    value = self._check(cid[option], 'Global', option, None)
                    if value is not None:
                        cid[option] = value

        return cids

    def get_excluded(self):
        '''@brief Return the list of excluded controllers in the config file.
        Each excluded controller is in the form of a dictionary
        as follows. All the keys are optional.
        {
            'transport':  [TRANSPORT],
            'traddr':     [TRADDR],
            'trsvcid':    [TRSVCID],
            'host-iface': [IFACE],
            'subsysnqn':  [NQN],
        }
        '''
        controller_list = self.get_option('Controllers', 'exclude')

        # 2022-09-20: Look for "blacklist". This is for backwards compatibility
        # with releases 1.0 to 1.1.x. This is to be phased out (i.e. remove by 2024)
        controller_list += self.get_option('Controllers', 'blacklist')

        excluded = [_parse_controller(controller) for controller in controller_list]
        for controller in excluded:
            controller.pop('host-traddr', None)  # remove host-traddr
            try:
                # replace 'nqn' key by 'subsysnqn', if present.
                controller['subsysnqn'] = controller.pop('nqn')
            except KeyError:
                pass
        return excluded

    def _check(self, text, section, option, default):
        checker = self.OPTION_CHECKER[section][option]
        text_checker = checker.get('txt-chk', None)
        if text_checker is not None and not text_checker(text):
            logging.warning(
                'File:%s [%s]: %s - Text check found invalid value "%s". Default will be used',
                self.conf_file,
                section,
                option,
                text,
            )
            return self._defaults.get((section, option), default)

        converter = checker.get('convert', None)
        try:
            value = converter(text)
        except InvalidOption:
            logging.warning(
                'File:%s [%s]: %s - Data converter found invalid value "%s". Default will be used',
                self.conf_file,
                section,
                option,
                text,
            )
            return self._defaults.get((section, option), default)

        value_in_range = checker.get('rng-chk', None)
        if value_in_range is not None:
            expected_range = value_in_range(value)
            if expected_range is not None:
                logging.warning(
                    'File:%s [%s]: %s - "%s" is not within range %s..%s. Default will be used',
                    self.conf_file,
                    section,
                    option,
                    value,
                    min(expected_range),
                    max(expected_range),
                )
                return self._defaults.get((section, option), default)

        list_checker = checker.get('lst-chk', None)
        if list_checker:
            values = set()
            for item in value:
                if item not in list_checker:
                    logging.warning(
                        'File:%s [%s]: %s - List checker found invalid item "%s" will be ignored.',
                        self.conf_file,
                        section,
                        option,
                        item,
                    )
                else:
                    values.add(item)

            if len(values) == 0:
                return self._defaults.get((section, option), default)

            value = list(values)

        return value

    def _read_conf_file(self):
        '''@brief Read the configuration file if the file exists.'''
        config = configparser.ConfigParser(
            default_section=None,
            allow_no_value=True,
            delimiters=('='),
            interpolation=None,
            strict=False,
            dict_type=OrderedMultisetDict,
        )
        if self._conf_file and os.path.isfile(self._conf_file):
            config.read(self._conf_file)

        # Parse Configuration and validate.
        if self._valid_conf is not None:
            invalid_sections = set()
            for section in config.sections():
                if section not in self._valid_conf:
                    invalid_sections.add(section)
                else:
                    invalid_options = set()
                    for option in config.options(section):
                        if option not in self._valid_conf.get(section, []):
                            invalid_options.add(option)

                    if len(invalid_options) != 0:
                        logging.error(
                            'File:%s [%s] contains invalid options: %s',
                            self.conf_file,
                            section,
                            invalid_options,
                        )

            if len(invalid_sections) != 0:
                logging.error(
                    'File:%s contains invalid sections: %s',
                    self.conf_file,
                    invalid_sections,
                )

        return config


# ******************************************************************************
class SysConf(metaclass=singleton.Singleton):
    '''Read and cache the host configuration file.'''

    def __init__(self, conf_file=defs.SYS_CONF_FILE):
        self._config = None
        self._conf_file = conf_file
        self.reload()

    def reload(self):
        '''@brief Reload the configuration file.'''
        self._config = self._read_conf_file()

    @property
    def conf_file(self):
        '''Return the configuration file name'''
        return self._conf_file

    def set_conf_file(self, fname):
        '''Set the configuration file name and reload config'''
        self._conf_file = fname
        self.reload()

    def as_dict(self):
        '''Return configuration as a dictionary'''
        return {
            'hostnqn': self.hostnqn,
            'hostid': self.hostid,
            'hostkey': self.hostkey,
            'symname': self.hostsymname,
        }

    @property
    def hostnqn(self):
        '''@brief return the host NQN
        @return: Host NQN
        @raise: Host NQN is mandatory. The program will terminate if a
                Host NQN cannot be determined.
        '''
        try:
            value = self.__get_value('Host', 'nqn', defs.NVME_HOSTNQN)
        except FileNotFoundError as ex:
            sys.exit(f'Error reading mandatory Host NQN (see stasadm --help): {ex}')

        if value is not None and not value.startswith('nqn.'):
            sys.exit(f'Error Host NQN "{value}" should start with "nqn."')

        return value

    @property
    def hostid(self):
        '''@brief return the host ID
        @return: Host ID
        @raise: Host ID is mandatory. The program will terminate if a
                Host ID cannot be determined.
        '''
        try:
            value = self.__get_value('Host', 'id', defs.NVME_HOSTID)
        except FileNotFoundError as ex:
            sys.exit(f'Error reading mandatory Host ID (see stasadm --help): {ex}')

        return value

    @property
    def hostkey(self):
        '''@brief return the host key
        @return: Host key
        @raise: Host key is optional, but mandatory if authorization will be performed.
        '''
        try:
            value = self.__get_value('Host', 'key', defs.NVME_HOSTKEY)
        except FileNotFoundError as ex:
            logging.debug('Host key undefined: %s', ex)
            value = None

        return value

    @property
    def hostsymname(self):
        '''@brief return the host symbolic name (or None)
        @return: symbolic name or None
        '''
        try:
            value = self.__get_value('Host', 'symname')
        except FileNotFoundError as ex:
            logging.warning('Error reading host symbolic name (will remain undefined): %s', ex)
            value = None

        return value

    def _read_conf_file(self):
        '''@brief Read the configuration file if the file exists.'''
        config = configparser.ConfigParser(
            default_section=None, allow_no_value=True, delimiters=('='), interpolation=None, strict=False
        )
        if os.path.isfile(self._conf_file):
            config.read(self._conf_file)
        return config

    def __get_value(self, section, option, default_file=None):
        '''@brief A configuration file consists of sections, each led by a
               [section] header, followed by key/value entries separated
               by a equal sign (=). This method retrieves the value
               associated with the key @option from the section @section.
               If the value starts with the string "file://", then the value
               will be retrieved from that file.

        @param section:      Configuration section
        @param option:       The key to look for
        @param default_file: A file that contains the default value

        @return: On success, the value associated with the key. On failure,
                 this method will return None is a default_file is not
                 specified, or will raise an exception if a file is not
                 found.

        @raise: This method will raise the FileNotFoundError exception if
                the value retrieved is a file that does not exist.
        '''
        try:
            value = self._config.get(section=section, option=option)
            if not value.startswith('file://'):
                return value
            file = value[7:]
        except (configparser.NoSectionError, configparser.NoOptionError, KeyError):
            if default_file is None:
                return None
            file = default_file

        try:
            with open(file) as f:  # pylint: disable=unspecified-encoding
                return f.readline().split()[0]
        except IndexError:
            return None


# ******************************************************************************
class NvmeOptions(metaclass=singleton.Singleton):
    '''Object used to read and cache contents of file /dev/nvme-fabrics.
    Note that this file was not readable prior to Linux 5.16.
    '''

    def __init__(self):
        # Supported options can be determined by looking at the kernel version
        # or by reading '/dev/nvme-fabrics'. The ability to read the options
        # from '/dev/nvme-fabrics' was only introduced in kernel 5.17, but may
        # have been backported to older kernels. In any case, if the kernel
        # version meets the minimum version for that option, then we don't
        # even need to read '/dev/nvme-fabrics'.
        self._supported_options = {
            'discovery': defs.KERNEL_VERSION >= defs.KERNEL_TP8013_MIN_VERSION,
            'host_iface': defs.KERNEL_VERSION >= defs.KERNEL_IFACE_MIN_VERSION,
            'dhchap_secret': defs.KERNEL_VERSION >= defs.KERNEL_HOSTKEY_MIN_VERSION,
            'dhchap_ctrl_secret': defs.KERNEL_VERSION >= defs.KERNEL_CTRLKEY_MIN_VERSION,
        }

        # If some of the options are False, we need to check 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']


# ******************************************************************************
class NbftConf(metaclass=singleton.Singleton):
    '''Read and cache configuration file.'''

    def __init__(self, root_dir=defs.NBFT_SYSFS_PATH):
        self._disc_ctrls = []
        self._subs_ctrls = []

        nbft_files = nbft.get_nbft_files(root_dir)
        if len(nbft_files):
            logging.info('NBFT location(s): %s', list(nbft_files.keys()))

        for data in nbft_files.values():
            hfis = data.get('hfi', [])
            discovery = data.get('discovery', [])
            subsystem = data.get('subsystem', [])
            host = data.get('host', {})
            hostnqn = host.get('nqn', None) if host.get('host_nqn_configured', False) else None

            self._disc_ctrls.extend(NbftConf.__nbft_disc_to_cids(hostnqn, discovery, hfis))
            self._subs_ctrls.extend(NbftConf.__nbft_subs_to_cids(hostnqn, subsystem, hfis))

    dcs = property(lambda self: self._disc_ctrls)
    iocs = property(lambda self: self._subs_ctrls)

    def get_controllers(self):
        '''Retrieve the list of controllers. Stafd only cares about
        discovery controllers. Stacd only cares about I/O controllers.'''

        # For now, only return DCs. There are still unanswered questions
        # regarding I/O controllers, e.g. what if multipathing has been
        # configured.
        return self.dcs if defs.PROG_NAME == 'stafd' else []

    @staticmethod
    def __nbft_disc_to_cids(hostnqn, discovery, hfis):
        cids = []

        for ctrl in discovery:
            cid = NbftConf.__uri2cid(ctrl['uri'])
            cid['subsysnqn'] = ctrl['nqn']
            if hostnqn:
                cid['host-nqn'] = hostnqn

            host_iface = NbftConf.__get_host_iface(ctrl.get('hfi_index'), hfis)
            if host_iface:
                cid['host-iface'] = host_iface

            cids.append(cid)

        return cids

    @staticmethod
    def __nbft_subs_to_cids(hostnqn, subsystem, hfis):
        cids = []

        for ctrl in subsystem:
            cid = {
                'transport': ctrl['trtype'],
                'traddr': ctrl['traddr'],
                'trsvcid': ctrl['trsvcid'],
                'subsysnqn': ctrl['subsys_nqn'],
                'hdr-digest': ctrl['pdu_header_digest_required'],
                'data-digest': ctrl['data_digest_required'],
            }
            if hostnqn:
                cid['host-nqn'] = hostnqn

            indexes = ctrl.get('hfi_indexes')
            if isinstance(indexes, list) and len(indexes) > 0:
                host_iface = NbftConf.__get_host_iface(indexes[0], hfis)
                if host_iface:
                    cid['host-iface'] = host_iface

            cids.append(cid)

        return cids

    @staticmethod
    def __get_host_iface(indx, hfis):
        if indx is None or indx >= len(hfis):
            return None

        mac = hfis[indx].get('mac_addr')
        if mac is None:
            return None

        return iputil.mac2iface(mac)

    @staticmethod
    def __uri2cid(uri: str):
        '''Convert a URI of the form "nvme+tcp://100.71.103.50:8009/" to a Controller ID'''
        obj = urlparse(uri)
        return {
            'transport': obj.scheme.split('+')[1],
            'traddr': obj.hostname,
            'trsvcid': str(obj.port),
        }