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