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