1""" 2Definition of the ConfigResolver to configure Lexicon, and convenient classes to build 3various configuration sources. 4""" 5import logging 6import os 7import re 8import warnings 9from argparse import Namespace 10from typing import Any, Dict, Optional 11 12import yaml 13 14LOGGER = logging.getLogger(__name__) 15 16 17class ConfigSource(object): 18 """ 19 Base class to implement a configuration source for a ConfigResolver. 20 The relevant method to override is resolve(self, config_parameter). 21 """ 22 23 def resolve(self, config_key): 24 """ 25 Using the given config_parameter value (in the form of 'lexicon:config_key' or 26 'lexicon:[provider]:config_key'), try to get the associated value. 27 28 None must be returned if no value could be found. 29 30 Must be implemented by each ConfigSource concrete child class. 31 """ 32 raise NotImplementedError( 33 "The method resolve(config_key) " 34 "must be implemented in the concret sub-classes." 35 ) 36 37 38class EnvironmentConfigSource(ConfigSource): 39 """ConfigSource that resolve configuration against existing environment variables.""" 40 41 def __init__(self): 42 super(EnvironmentConfigSource, self).__init__() 43 self._parameters = {} 44 for (key, value) in os.environ.items(): 45 if key.startswith("LEXICON_"): 46 self._parameters[key] = value 47 48 def resolve(self, config_key): 49 # First try, with a direct conversion of the config_parameter: 50 # * lexicon:provider:auth_my_config => LEXICON_PROVIDER_AUTH_MY_CONFIG 51 # * lexicon:provider:my_other_config => LEXICON_PROVIDER_AUTH_MY_OTHER_CONFIG 52 # * lexicon:my_global_config => LEXICON_MY_GLOBAL_CONFIG 53 environment_variable = re.sub(":", "_", config_key).upper() 54 value = self._parameters.get(environment_variable, None) 55 if value: 56 return value 57 58 # Second try, with the legacy naming convention for specific provider config: 59 # * lexicon:provider:auth_my_config => LEXICON_PROVIDER_MY_CONFIG 60 # Users get a warning about this deprecated usage. 61 environment_variable_legacy = re.sub( 62 r"(.*)_AUTH_(.*)", r"\1_\2", environment_variable 63 ).upper() 64 value = self._parameters.get(environment_variable_legacy, None) 65 if value: 66 LOGGER.warning( 67 ( 68 "Warning: Use of environment variable %s is deprecated. " 69 "Try %s instead." 70 ), 71 environment_variable_legacy, 72 environment_variable, 73 ) 74 return value 75 76 return None 77 78 79class ArgsConfigSource(ConfigSource): 80 """ConfigSource that resolve configuration against an argparse namespace.""" 81 82 def __init__(self, namespace): 83 super(ArgsConfigSource, self).__init__() 84 self._parameters = vars(namespace) 85 86 def resolve(self, config_key): 87 # We assume here that the namespace provided has already done its job, 88 # by validating that all given parameters are relevant for Lexicon or the current provider. 89 # So we ignore the namespaces 'lexicon:' and 'lexicon:provider' in given config key. 90 splitted_config_key = config_key.split(":") 91 92 return self._parameters.get(splitted_config_key[-1], None) 93 94 95class DictConfigSource(ConfigSource): 96 """ConfigSource that resolve configuration against a dict object.""" 97 98 def __init__(self, dict_object): 99 super(DictConfigSource, self).__init__() 100 self._parameters = dict_object 101 102 def resolve(self, config_key): 103 splitted_config_key = config_key.split(":") 104 # Note that we ignore 'lexicon:' in the iteration, 105 # as the dict object is already scoped to lexicon. 106 cursor = self._parameters 107 for current in splitted_config_key[1:-1]: 108 cursor = cursor.get(current, {}) 109 110 return cursor.get(splitted_config_key[-1], None) 111 112 113class FileConfigSource(DictConfigSource): 114 """ConfigSource that resolve configuration against a lexicon config file.""" 115 116 def __init__(self, file_path): 117 with open(file_path, "r") as stream: 118 yaml_object = yaml.load(stream, Loader=yaml.SafeLoader) or {} 119 120 super(FileConfigSource, self).__init__(yaml_object) 121 122 123class ProviderFileConfigSource(FileConfigSource): 124 """ConfigSource that resolve configuration against an provider config file.""" 125 126 def __init__(self, provider_name, file_path): 127 super(ProviderFileConfigSource, self).__init__(file_path) 128 # Scope the loaded config file into provider namespace 129 self._parameters = {provider_name: self._parameters} 130 131 132class LegacyDictConfigSource(DictConfigSource): 133 """ConfigSource that resolve configuration against a legacy Lexicon 2.x dict object.""" 134 135 def __init__(self, dict_object): 136 provider_name = dict_object.get("provider_name") or dict_object.get("provider") 137 if not provider_name: 138 raise AttributeError( 139 "Error, key provider_name is not defined." 140 "LegacyDictConfigSource cannot scope correctly " 141 "the provider specific options." 142 ) 143 144 generic_parameters = [ 145 "domain", 146 "action", 147 "provider_name", 148 "delegated", 149 "identifier", 150 "type", 151 "name", 152 "content", 153 "ttl", 154 "priority", 155 "log_level", 156 "output", 157 ] 158 159 provider_options = {} 160 refactor_dict_object = {} 161 refactor_dict_object[provider_name] = provider_options 162 163 for (key, value) in dict_object.items(): 164 if key not in generic_parameters: 165 provider_options[key] = value 166 else: 167 refactor_dict_object[key] = value 168 169 super(LegacyDictConfigSource, self).__init__(refactor_dict_object) 170 171 172class ConfigResolver(object): 173 """ 174 Highly customizable configuration resolver object, that gets configuration parameters 175 from various sources with a precedence order. Sources and their priority are configured 176 by calling the with* methods of this object, in the decreasing priority order. 177 178 A configuration parameter can be retrieved using the resolve() method. The configuration 179 parameter key needs to conform to a namespace, whose delimeters is ':'. Two namespaces will 180 be used in the context of Lexicon: 181 * the parameters relevant for Lexicon itself: 'lexicon:global_parameter' 182 * the parameters specific to a DNS provider: 'lexicon:cloudflare:cloudflare_parameter' 183 184 Example: 185 # This will resolve configuration parameters from environment variables, 186 # then from a configuration file named '/my/path/to/lexicon.yml'. 187 $ from lexicon.config import ConfigResolver 188 $ config = ConfigResolver() 189 $ config.with_env().with_config_file() 190 $ print(config.resolve('lexicon:delegated')) 191 $ print(config.resolve('lexicon:cloudflare:auth_token)) 192 193 Config can resolve parameters for Lexicon and providers from: 194 * environment variables 195 * arguments parsed by ArgParse library 196 * YAML configuration files, generic or specific to a provider 197 * any object implementing the underlying ConfigSource class 198 199 Each parameter will be resolved against each source, and value from the higher priority source 200 is returned. If a parameter could not be resolve by any source, then None will be returned. 201 """ 202 203 def __init__(self): 204 super(ConfigResolver, self).__init__() 205 self._config_sources = [] 206 207 def resolve(self, config_key: str) -> Optional[Any]: 208 """ 209 Resolve the value of the given config parameter key. Key must be correctly scoped for 210 Lexicon, and optionally for the DNS provider for which the parameter is consumed. 211 For instance: 212 * config.resolve('lexicon:delegated') will get the delegated parameter for Lexicon 213 * config.resolve('lexicon:cloudflare:auth_token') will get the auth_token parameter 214 consumed by cloudflare DNS provider. 215 216 Value is resolved against each configured source, and value from the highest priority source 217 is returned. None will be returned if the given config parameter key could not be resolved 218 from any source. 219 """ 220 for config_source in self._config_sources: 221 value = config_source.resolve(config_key) 222 if value: 223 return value 224 225 return None 226 227 def add_config_source( 228 self, config_source: ConfigSource, position: Optional[int] = None 229 ) -> None: 230 """ 231 Add a config source to the current ConfigResolver instance. 232 If position is not set, this source will be inserted with the lowest priority. 233 """ 234 rank = position if position is not None else len(self._config_sources) 235 self._config_sources.insert(rank, config_source) 236 237 def with_config_source(self, config_source: ConfigSource) -> "ConfigResolver": 238 """ 239 Configure current resolver to use the provided ConfigSource instance to be used as a source. 240 See documentation of ConfigSource to see how to implement correctly a ConfigSource. 241 """ 242 self.add_config_source(config_source) 243 return self 244 245 def with_env(self) -> "ConfigResolver": 246 """ 247 Configure current resolver to use available environment variables as a source. 248 Only environment variables starting with 'LEXICON' or 'LEXICON_[PROVIDER]' 249 will be taken into account. 250 """ 251 return self.with_config_source(EnvironmentConfigSource()) 252 253 def with_args(self, argparse_namespace: Namespace) -> "ConfigResolver": 254 """ 255 Configure current resolver to use a Namespace object given by a ArgParse instance 256 using arg_parse() as a source. This method is typically used to allow a ConfigResolver 257 to get parameters from the command line. 258 259 It is assumed that the argument parser have already checked that provided arguments are 260 valid for Lexicon or the current provider. No further namespace check on parameter keys will 261 be done here. Meaning that if 'lexicon:cloudflare:auth_token' is asked, any auth_token 262 present in the given Namespace object will be returned. 263 """ 264 return self.with_config_source(ArgsConfigSource(argparse_namespace)) 265 266 def with_dict(self, dict_object: Dict) -> "ConfigResolver": 267 """ 268 Configure current resolver to use the given dict object, scoped to lexicon namespace. 269 Example of valid dict object for lexicon: 270 { 271 'delegated': 'onedelegated', 272 'cloudflare': { 273 'auth_token': 'SECRET_TOKEN' 274 } 275 } 276 => Will define properties 'lexicon:delegated' and 'lexicon:cloudflare:auth_token' 277 """ 278 return self.with_config_source(DictConfigSource(dict_object)) 279 280 def with_config_file(self, file_path: str) -> "ConfigResolver": 281 """ 282 Configure current resolver to use a YAML configuration file specified on the given path. 283 This file provides configuration parameters for Lexicon and any DNS provider. 284 285 Typical format is: 286 $ cat lexicon.yml 287 # Will define properties 'lexicon:delegated' and 'lexicon:cloudflare:auth_token' 288 delegated: 'onedelegated' 289 cloudflare: 290 auth_token: SECRET_TOKEN 291 """ 292 return self.with_config_source(FileConfigSource(file_path)) 293 294 def with_provider_config_file( 295 self, provider_name: str, file_path: str 296 ) -> "ConfigResolver": 297 """ 298 Configure current resolver to use a YAML configuration file specified on the given path. 299 This file provides configuration parameters for a DNS provider exclusively. 300 301 Typical format is: 302 $ cat lexicon_cloudflare.yml 303 # Will define properties 'lexicon:cloudflare:auth_token' 304 # and 'lexicon:cloudflare:auth_username' 305 auth_token: SECRET_TOKEN 306 auth_username: USERNAME 307 308 NB: If file_path is not specified, '/etc/lexicon/lexicon_[provider].yml' will be taken 309 by default, with [provider] equals to the given provider_name parameter. 310 """ 311 return self.with_config_source( 312 ProviderFileConfigSource(provider_name, file_path) 313 ) 314 315 def with_config_dir(self, dir_path: str) -> "ConfigResolver": 316 """ 317 Configure current resolver to use every valid YAML configuration files available in the 318 given directory path. To be taken into account, a configuration file must conform to the 319 following naming convention: 320 * 'lexicon.yml' for a global Lexicon config file (see with_config_file doc) 321 * 'lexicon_[provider].yml' for a DNS provider specific configuration file, with 322 [provider] equals to the DNS provider name (see with_provider_config_file doc) 323 324 Example: 325 $ ls /etc/lexicon 326 lexicon.yml # global Lexicon configuration file 327 lexicon_cloudflare.yml # specific configuration file for clouflare DNS provder 328 """ 329 lexicon_provider_config_files = [] 330 lexicon_config_files = [] 331 332 for path in os.listdir(dir_path): 333 path = os.path.join(dir_path, path) 334 if os.path.isfile(path): 335 basename = os.path.basename(path) 336 search = re.search(r"^lexicon(?:_(\w+)|)\.yml$", basename) 337 if search: 338 provider = search.group(1) 339 if provider: 340 lexicon_provider_config_files.append((provider, path)) 341 else: 342 lexicon_config_files.append(path) 343 344 for lexicon_provider_config_file in lexicon_provider_config_files: 345 self.with_provider_config_file( 346 lexicon_provider_config_file[0], lexicon_provider_config_file[1] 347 ) 348 349 for lexicon_config_file in lexicon_config_files: 350 self.with_config_file(lexicon_config_file) 351 352 return self 353 354 def with_legacy_dict(self, legacy_dict_object: Dict) -> "ConfigResolver": 355 """Configure a source that consumes the dict that where used on Lexicon 2.x""" 356 warnings.warn( 357 DeprecationWarning( 358 "Legacy configuration object has been used " 359 "to load the ConfigResolver." 360 ) 361 ) 362 return self.with_config_source(LegacyDictConfigSource(legacy_dict_object)) 363 364 365def non_interactive_config_resolver() -> ConfigResolver: 366 """ 367 Create a typical config resolver in a non-interactive context (eg. lexicon used as a library). 368 Configuration will be resolved againts env variables and lexicon config files in working dir. 369 """ 370 return ConfigResolver().with_env().with_config_dir(os.getcwd()) 371 372 373def legacy_config_resolver(legacy_dict) -> ConfigResolver: 374 """ 375 With the old legacy approach, we juste got a plain configuration dict object. 376 Custom logic was to enrich this configuration with env variables. 377 378 This function create a resolve that respect the expected behavior, by using the relevant 379 ConfigSources, and we add the config files from working directory. 380 """ 381 return ( 382 ConfigResolver() 383 .with_legacy_dict(legacy_dict) 384 .with_env() 385 .with_config_dir(os.getcwd()) 386 ) 387