1#!/usr/bin/env python3
2# Copyright 2018 Red Hat, Inc.
3#
4#    Licensed under the Apache License, Version 2.0 (the "License"); you may
5#    not use this file except in compliance with the License. You may obtain
6#    a copy of the License at
7#
8#         http://www.apache.org/licenses/LICENSE-2.0
9#
10#    Unless required by applicable law or agreed to in writing, software
11#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13#    License for the specific language governing permissions and limitations
14#    under the License.
15
16"""Configuration Validator
17
18Uses the sample config generator configuration file to retrieve a list of all
19the available options in a project, then compares it to what is configured in
20the provided file.  If there are any options set that are not defined in the
21project then it returns those errors.
22"""
23
24import logging
25import re
26import sys
27
28try:
29    # For Python 3.8 and later
30    import importlib.metadata as importlib_metadata
31except ImportError:
32    # For everyone else
33    import importlib_metadata
34
35import yaml
36
37from oslo_config import cfg
38from oslo_config import generator
39
40VALIDATE_DEFAULTS_EXCLUSIONS = [
41    '.*_ur(i|l)', '.*connection', 'password', 'username', 'my_ip',
42    'host(name)?', 'glance_api_servers', 'osapi_volume_listen',
43    'osapi_compute_listen',
44]
45
46_validator_opts = [
47    cfg.MultiStrOpt(
48        'namespace',
49        help='Option namespace under "oslo.config.opts" in which to query '
50             'for options.'),
51    cfg.StrOpt(
52        'input-file',
53        required=True,
54        help='Config file to validate.'),
55    cfg.StrOpt(
56        'opt-data',
57        help='Path to a YAML file containing definitions of options, as '
58             'output by the config generator.'),
59    cfg.BoolOpt(
60        'check-defaults',
61        default=False,
62        help='Report differences between the sample values and current '
63             'values.'),
64    cfg.ListOpt(
65        'exclude-options',
66        default=VALIDATE_DEFAULTS_EXCLUSIONS,
67        help='Exclude options matching these patterns when comparing '
68             'the current and sample configurations.'),
69    cfg.BoolOpt(
70        'fatal-warnings',
71        default=False,
72        help='Report failure if any warnings are found.'),
73    cfg.MultiStrOpt(
74        'exclude-group',
75        default=[],
76        help='Groups that should not be validated if they are present in the '
77             'specified input-file. This may be necessary for dynamically '
78             'named groups which do not appear in the sample config data.'),
79]
80
81
82KNOWN_BAD_GROUPS = ['keystone_authtoken']
83
84
85def _register_cli_opts(conf):
86    """Register the formatter's CLI options with a ConfigOpts instance.
87
88    Note, this must be done before the ConfigOpts instance is called to parse
89    the configuration.
90
91    :param conf: a ConfigOpts instance
92    :raises: DuplicateOptError, ArgsAlreadyParsedError
93    """
94    conf.register_cli_opts(_validator_opts)
95
96
97def _validate_deprecated_opt(group, option, opt_data):
98    if group not in opt_data['deprecated_options']:
99        return False
100    name_data = [o['name'] for o in opt_data['deprecated_options'][group]]
101    name_data += [o.get('dest') for o in opt_data['deprecated_options'][group]]
102    return option in name_data
103
104
105def _validate_defaults(sections, opt_data, conf):
106    """Compares the current and sample configuration and reports differences
107
108    :param section: ConfigParser instance
109    :param opt_data: machine readable data from the generator instance
110    :param conf: ConfigOpts instance
111    :returns: boolean wether or not warnings were reported
112    """
113    warnings = False
114    # Generating regex objects from ListOpt
115    exclusion_regexes = []
116    for pattern in conf.exclude_options:
117        exclusion_regexes.append(re.compile(pattern))
118    for group, opts in opt_data['options'].items():
119        if group in conf.exclude_group:
120            continue
121        if group not in sections:
122            logging.warning(
123                'Group %s from the sample config is not defined in '
124                'input-file', group)
125            continue
126        for opt in opts['opts']:
127            # We need to convert the defaults into a list to find
128            # intersections. defaults are only a list if they can
129            # be defined multiple times, but configparser only
130            # returns list
131            if not isinstance(opt['default'], list):
132                defaults = [str(opt['default'])]
133            else:
134                defaults = opt['default']
135
136            # Apparently, there's multiple naming conventions for
137            # options, 'name' is mostly with hyphens, and 'dest'
138            # is represented with underscores.
139            opt_names = set([opt['name'], opt.get('dest')])
140            if not opt_names.intersection(sections[group]):
141                continue
142            try:
143                value = sections[group][opt['name']]
144                keyname = opt['name']
145            except KeyError:
146                value = sections[group][opt.get('dest')]
147                keyname = opt.get('dest')
148
149            if any(rex.fullmatch(keyname) for rex in exclusion_regexes):
150                logging.info(
151                    '%s/%s Ignoring option because it is part of the excluded '
152                    'patterns. This can be changed with the --exclude-options '
153                    'argument', group, keyname)
154                continue
155
156            if len(value) > 1:
157                logging.info(
158                    '%s/%s defined %s times', group, keyname, len(value))
159            if not opt['default']:
160                logging.warning(
161                    '%s/%s sample value is empty but input-file has %s',
162                    group, keyname, ", ".join(value))
163                warnings = True
164            elif not frozenset(defaults).intersection(value):
165                logging.warning(
166                    '%s/%s sample value %s is not in %s',
167                    group, keyname, defaults, value)
168                warnings = True
169    return warnings
170
171
172def _validate_opt(group, option, opt_data):
173    if group not in opt_data['options']:
174        return False
175    name_data = [o['name'] for o in opt_data['options'][group]['opts']]
176    name_data += [o.get('dest') for o in opt_data['options'][group]['opts']]
177    return option in name_data
178
179
180def load_opt_data(conf):
181    with open(conf.opt_data) as f:
182        return yaml.safe_load(f)
183
184
185def _validate(conf):
186    conf.register_opts(_validator_opts)
187    if conf.namespace:
188        groups = generator._get_groups(generator._list_opts(conf.namespace))
189        opt_data = generator._generate_machine_readable_data(groups, conf)
190    elif conf.opt_data:
191        opt_data = load_opt_data(conf)
192    else:
193        # TODO(bnemec): Implement this logic with group?
194        raise RuntimeError('Neither namespace nor opt-data provided.')
195    sections = {}
196    parser = cfg.ConfigParser(conf.input_file, sections)
197    parser.parse()
198    warnings = False
199    errors = False
200    if conf.check_defaults:
201        warnings = _validate_defaults(sections, opt_data, conf)
202    for section, options in sections.items():
203        if section in conf.exclude_group:
204            continue
205        for option in options:
206            if _validate_deprecated_opt(section, option, opt_data):
207                logging.warning('Deprecated opt %s/%s found', section, option)
208                warnings = True
209            elif not _validate_opt(section, option, opt_data):
210                if section in KNOWN_BAD_GROUPS:
211                    logging.info('Ignoring missing option "%s" from group '
212                                 '"%s" because the group is known to have '
213                                 'incomplete sample config data and thus '
214                                 'cannot be validated properly.',
215                                 option, section)
216                    continue
217                logging.error('%s/%s is not part of the sample config',
218                              section, option)
219                errors = True
220    if errors or (warnings and conf.fatal_warnings):
221        return 1
222    return 0
223
224
225def main():
226    """The main function of oslo-config-validator."""
227    version = importlib_metadata.version('oslo.config')
228    logging.basicConfig(level=logging.INFO)
229    conf = cfg.ConfigOpts()
230    _register_cli_opts(conf)
231    try:
232        conf(sys.argv[1:], version=version)
233    except cfg.RequiredOptError:
234        conf.print_help()
235        if not sys.argv[1:]:
236            raise SystemExit
237        raise
238    return _validate(conf)
239
240
241if __name__ == '__main__':
242    sys.exit(main())
243