diff options
author | Tomás Touceda <chiiph@leap.se> | 2013-03-06 15:36:37 -0300 |
---|---|---|
committer | Tomás Touceda <chiiph@leap.se> | 2013-03-06 15:36:37 -0300 |
commit | d4452e77afc63ec49684ef4b6cf6459456d293e1 (patch) | |
tree | af581adedfd04dd0d4aa3759eae99787cd4764a3 | |
parent | a11bcd4e3ee699f8050b21ef5d31b9f6031ddd7f (diff) |
Add basic config handling
-rw-r--r-- | src/leap/config/__init__.py | 0 | ||||
-rw-r--r-- | src/leap/config/baseconfig.py | 181 | ||||
-rw-r--r-- | src/leap/config/pluggableconfig.py | 473 | ||||
-rw-r--r-- | src/leap/config/prefixers.py | 86 | ||||
-rw-r--r-- | src/leap/config/provider_spec.py | 75 | ||||
-rw-r--r-- | src/leap/config/providerconfig.py | 144 |
6 files changed, 959 insertions, 0 deletions
diff --git a/src/leap/config/__init__.py b/src/leap/config/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/src/leap/config/__init__.py diff --git a/src/leap/config/baseconfig.py b/src/leap/config/baseconfig.py new file mode 100644 index 00000000..d553255e --- /dev/null +++ b/src/leap/config/baseconfig.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- +# baseconfig.py +# Copyright (C) 2013 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +""" +Implements the abstract base class for configuration +""" + +import logging +import functools +import os +import errno +import copy + +from abc import ABCMeta, abstractmethod + +from leap.config.prefixers import get_platform_prefixer +from leap.config.pluggableconfig import PluggableConfig + +logger = logging.getLogger(__name__) + + +class BaseConfig: + """ + Abstract base class for any JSON based configuration + """ + + __metaclass__ = ABCMeta + + def __init__(self): + self._data = {} + self._config_checker = None + + @abstractmethod + def _get_spec(self): + """ + Returns the spec object for the specific configuration + """ + return None + + def _safe_get_value(self, key): + """ + Tries to return a value only if the config has already been loaded + + @rtype: depends on the config structure, dict, str, array, int + @return: returns the value for the specified key in the config + """ + assert self._config_checker, "Load the config first" + return self._config_checker.config[key] + + def get_path_prefix(self, standalone=False): + """ + Returns the platform dependant path prefixer + + @param standalone: if True it will return the prefix for a + standalone application. Otherwise, it will return the system + default for configuration storage. + @param type: bool + """ + return get_platform_prefixer().get_path_prefix(standalone=standalone) + + def loaded(self): + """ + Returns True if the configuration has been already + loaded. False otherwise + """ + return self._config_checker is not None + + def save(self, path_list): + """ + Saves the current configuration to disk + + @param path: relative path to configuration. The absolute path + will be calculated depending on the platform. + @type path: list + + @return: True if saved to disk correctly, False otherwise + """ + config_path = os.path.join(self.get_path_prefix(), *(path_list[:-1])) + try: + os.makedirs(config_path) + except OSError as e: + if e.errno == errno.EEXIST and os.path.isdir(config_path): + pass + else: + raise + + try: + self._config_checker.serialize(os.path.join(config_path, + path_list[-1])) + except Exception as e: + logger.warning("%s" % (e,)) + raise + return True + + def load(self, path="", data=None): + """ + Loads the configuration from disk + + @type path: str + @param path: relative path to configuration. The absolute path + will be calculated depending on the platform + + @return: True if loaded to disk correctly, False otherwise + """ + + # TODO: retrieve standalone option from app-level config + config_path = os.path.join(self.get_path_prefix(), + path) + + self._config_checker = PluggableConfig(format="json") + self._config_checker.options = copy.deepcopy(self._get_spec()) + + try: + if data is None: + self._config_checker.load(fromfile=config_path) + else: + self._config_checker.load(data) + except Exception as e: + logger.warning("Something went wrong while loading " + + "the config from %s\n%s" % (config_path, e)) + self._config_checker = None + return False + return True + + +class LocalizedKey(object): + """ + Decorator used for keys that are localized in a configuration + """ + + def __init__(self, func, **kwargs): + self._func = func + + def __call__(self, instance, lang="en"): + """ + Tries to return the string for the specified language, otherwise + informs the problem and returns an empty string + + @param lang: language code + @param type: str + + @return: localized value from the possible values returned by + self._func + """ + descriptions = self._func(instance) + description_lang = "" + if lang in descriptions.keys(): + description_lang = descriptions[lang] + else: + logger.warning("Unknown language: %s" % (lang,)) + return description_lang + + def __get__(self, instance, instancetype): + """ + Implement the descriptor protocol to make decorating instance + method possible. + """ + # Return a partial function with the first argument is the instance + # of the class decorated. + return functools.partial(self.__call__, instance) + +if __name__ == "__main__": + try: + config = BaseConfig() # should throw TypeError for _get_spec + except Exception as e: + assert isinstance(e, TypeError), "Something went wrong" + print "Abstract BaseConfig class is working as expected" diff --git a/src/leap/config/pluggableconfig.py b/src/leap/config/pluggableconfig.py new file mode 100644 index 00000000..64aa05cc --- /dev/null +++ b/src/leap/config/pluggableconfig.py @@ -0,0 +1,473 @@ +# -*- coding: utf-8 -*- +# pluggableconfig.py +# Copyright (C) 2013 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +""" +generic configuration handlers +""" +import copy +import json +import logging +import os +import time +import urlparse + +import jsonschema + +#from leap.base.util.translations import LEAPTranslatable + +logger = logging.getLogger(__name__) + + +__all__ = ['PluggableConfig', + 'adaptors', + 'types', + 'UnknownOptionException', + 'MissingValueException', + 'ConfigurationProviderException', + 'TypeCastException'] + +# exceptions + + +class UnknownOptionException(Exception): + """exception raised when a non-configuration + value is present in the configuration""" + + +class MissingValueException(Exception): + """exception raised when a required value is missing""" + + +class ConfigurationProviderException(Exception): + """exception raised when a configuration provider is missing, etc""" + + +class TypeCastException(Exception): + """exception raised when a + configuration item cannot be coerced to a type""" + + +class ConfigAdaptor(object): + """ + abstract base class for config adaotors for + serialization/deserialization and custom validation + and type casting. + """ + def read(self, filename): + raise NotImplementedError("abstract base class") + + def write(self, config, filename): + with open(filename, 'w') as f: + self._write(f, config) + + def _write(self, fp, config): + raise NotImplementedError("abstract base class") + + def validate(self, config, schema): + raise NotImplementedError("abstract base class") + + +adaptors = {} + + +class JSONSchemaEncoder(json.JSONEncoder): + """ + custom default encoder that + casts python objects to json objects for + the schema validation + """ + def default(self, obj): + if obj is str: + return 'string' + if obj is unicode: + return 'string' + if obj is int: + return 'integer' + if obj is list: + return 'array' + if obj is dict: + return 'object' + if obj is bool: + return 'boolean' + + +class JSONAdaptor(ConfigAdaptor): + indent = 2 + extensions = ['json'] + + def read(self, _from): + if isinstance(_from, file): + _from_string = _from.read() + if isinstance(_from, str): + _from_string = _from + return json.loads(_from_string) + + def _write(self, fp, config): + fp.write(json.dumps(config, + indent=self.indent, + sort_keys=True)) + + def validate(self, config, schema_obj): + schema_json = JSONSchemaEncoder().encode(schema_obj) + schema = json.loads(schema_json) + jsonschema.validate(config, schema) + + +adaptors['json'] = JSONAdaptor() + +# +# Adaptors +# +# Allow to apply a predefined set of types to the +# specs, so it checks the validity of formats and cast it +# to proper python types. + +# TODO: +# - HTTPS uri + + +class DateType(object): + fmt = '%Y-%m-%d' + + def to_python(self, data): + return time.strptime(data, self.fmt) + + def get_prep_value(self, data): + return time.strftime(self.fmt, data) + + +class TranslatableType(object): + """ + a type that casts to LEAPTranslatable objects. + Used for labels we get from providers and stuff. + """ + + def to_python(self, data): + # TODO: add translatable + return data#LEAPTranslatable(data) + + # needed? we already have an extended dict... + #def get_prep_value(self, data): + #return dict(data) + + +class URIType(object): + + def to_python(self, data): + parsed = urlparse.urlparse(data) + if not parsed.scheme: + raise TypeCastException("uri %s has no schema" % data) + return parsed.geturl() + + def get_prep_value(self, data): + return data + + +class HTTPSURIType(object): + + def to_python(self, data): + parsed = urlparse.urlparse(data) + if not parsed.scheme: + raise TypeCastException("uri %s has no schema" % data) + if parsed.scheme != "https": + raise TypeCastException( + "uri %s does not has " + "https schema" % data) + return parsed.geturl() + + def get_prep_value(self, data): + return data + + +types = { + 'date': DateType(), + 'uri': URIType(), + 'https-uri': HTTPSURIType(), + 'translatable': TranslatableType(), +} + + +class PluggableConfig(object): + + options = {} + + def __init__(self, + adaptors=adaptors, + types=types, + format=None): + + self.config = {} + self.adaptors = adaptors + self.types = types + self._format = format + self.mtime = None + self.dirty = False + + @property + def option_dict(self): + if hasattr(self, 'options') and isinstance(self.options, dict): + return self.options.get('properties', None) + + def items(self): + """ + act like an iterator + """ + if isinstance(self.option_dict, dict): + return self.option_dict.items() + return self.options + + def validate(self, config, format=None): + """ + validate config + """ + schema = self.options + if format is None: + format = self._format + + if format: + adaptor = self.get_adaptor(self._format) + adaptor.validate(config, schema) + else: + # we really should make format mandatory... + logger.error('no format passed to validate') + + # first round of validation is ok. + # now we proceed to cast types if any specified. + self.to_python(config) + + def to_python(self, config): + """ + cast types following first type and then format indications. + """ + unseen_options = [i for i in config if i not in self.option_dict] + if unseen_options: + raise UnknownOptionException( + "Unknown options: %s" % ', '.join(unseen_options)) + + for key, value in config.items(): + _type = self.option_dict[key].get('type') + if _type is None and 'default' in self.option_dict[key]: + _type = type(self.option_dict[key]['default']) + if _type is not None: + tocast = True + if not callable(_type) and isinstance(value, _type): + tocast = False + if tocast: + try: + config[key] = _type(value) + except BaseException, e: + raise TypeCastException( + "Could not coerce %s, %s, " + "to type %s: %s" % (key, value, _type.__name__, e)) + _format = self.option_dict[key].get('format', None) + _ftype = self.types.get(_format, None) + if _ftype: + try: + config[key] = _ftype.to_python(value) + except BaseException, e: + raise TypeCastException( + "Could not coerce %s, %s, " + "to format %s: %s" % (key, value, + _ftype.__class__.__name__, + e)) + + return config + + def prep_value(self, config): + """ + the inverse of to_python method, + called just before serialization + """ + for key, value in config.items(): + _format = self.option_dict[key].get('format', None) + _ftype = self.types.get(_format, None) + if _ftype and hasattr(_ftype, 'get_prep_value'): + try: + config[key] = _ftype.get_prep_value(value) + except BaseException, e: + raise TypeCastException( + "Could not serialize %s, %s, " + "by format %s: %s" % (key, value, + _ftype.__class__.__name__, + e)) + else: + config[key] = value + return config + + # methods for adding configuration + + def get_default_values(self): + """ + return a config options from configuration defaults + """ + defaults = {} + for key, value in self.items(): + if 'default' in value: + defaults[key] = value['default'] + return copy.deepcopy(defaults) + + def get_adaptor(self, format): + """ + get specified format adaptor or + guess for a given filename + """ + adaptor = self.adaptors.get(format, None) + if adaptor: + return adaptor + + # not registered in adaptors dict, let's try all + for adaptor in self.adaptors.values(): + if format in adaptor.extensions: + return adaptor + + def filename2format(self, filename): + extension = os.path.splitext(filename)[-1] + return extension.lstrip('.') or None + + def serialize(self, filename, format=None, full=False): + if not format: + format = self._format + if not format: + format = self.filename2format(filename) + if not format: + raise Exception('Please specify a format') + # TODO: more specific exception type + + adaptor = self.get_adaptor(format) + if not adaptor: + raise Exception("Adaptor not found for format: %s" % format) + + config = copy.deepcopy(self.config) + serializable = self.prep_value(config) + adaptor.write(serializable, filename) + + if self.mtime: + self.touch_mtime(filename) + + def touch_mtime(self, filename): + mtime = self.mtime + os.utime(filename, (mtime, mtime)) + + def deserialize(self, string=None, fromfile=None, format=None): + """ + load configuration from a file or string + """ + + def _try_deserialize(): + if fromfile: + with open(fromfile, 'r') as f: + content = adaptor.read(f) + elif string: + content = adaptor.read(string) + return content + + # XXX cleanup this! + + if fromfile: + assert os.path.exists(fromfile) + if not format: + format = self.filename2format(fromfile) + + if not format: + format = self._format + if format: + adaptor = self.get_adaptor(format) + else: + adaptor = None + + if adaptor: + content = _try_deserialize() + return content + + # no adaptor, let's try rest of adaptors + + adaptors = self.adaptors[:] + + if format: + adaptors.sort( + key=lambda x: int( + format in x.extensions), + reverse=True) + + for adaptor in adaptors: + content = _try_deserialize() + return content + + def set_dirty(self): + self.dirty = True + + def is_dirty(self): + return self.dirty + + def load(self, *args, **kwargs): + """ + load from string or file + if no string of fromfile option is given, + it will attempt to load from defaults + defined in the schema. + """ + string = args[0] if args else None + fromfile = kwargs.get("fromfile", None) + mtime = kwargs.pop("mtime", None) + self.mtime = mtime + content = None + + # start with defaults, so we can + # have partial values applied. + content = self.get_default_values() + if string and isinstance(string, str): + content = self.deserialize(string) + + if not string and fromfile is not None: + #import ipdb;ipdb.set_trace() + content = self.deserialize(fromfile=fromfile) + + if not content: + logger.error('no content could be loaded') + # XXX raise! + return + + # lazy evaluation until first level of nesting + # to allow lambdas with context-dependant info + # like os.path.expanduser + for k, v in content.iteritems(): + if callable(v): + content[k] = v() + + self.validate(content) + self.config = content + return True + + +def testmain(): # pragma: no cover + + from tests import test_validation as t + import pprint + + config = PluggableConfig(_format="json") + properties = copy.deepcopy(t.sample_spec) + + config.options = properties + config.load(fromfile='data.json') + + print 'config' + pprint.pprint(config.config) + + config.serialize('/tmp/testserial.json') + +if __name__ == "__main__": + testmain() diff --git a/src/leap/config/prefixers.py b/src/leap/config/prefixers.py new file mode 100644 index 00000000..a33e68aa --- /dev/null +++ b/src/leap/config/prefixers.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# prefixers.py +# Copyright (C) 2013 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +""" +Platform dependant configuration path prefixers +""" +import os +import platform + +from abc import ABCMeta, abstractmethod +from xdg import BaseDirectory + + +class Prefixer: + """ + Abstract prefixer class + """ + + __metaclass__ = ABCMeta + + @abstractmethod + def get_path_prefix(self, standalone=False): + """ + Returns the platform dependant path prefixer + + @param standalone: if True it will return the prefix for a + standalone application. Otherwise, it will return the system + default for configuration storage. + @param type: bool + """ + return "" + + +def get_platform_prefixer(): + prefixer = globals()[platform.system() + "Prefixer"] + assert prefixer, "Unimplemented platform prefixer: %s" % \ + (platform.system(),) + return prefixer() + + +class LinuxPrefixer(Prefixer): + """ + Config prefixer for the Linux platform + """ + + def get_path_prefix(self, standalone=False): + """ + Returns the platform dependant path prefixer. + This method expects an env variable named LEAP_CLIENT_PATH if + standalone is used. + + @param standalone: if True it will return the prefix for a + standalone application. Otherwise, it will return the system + default for configuration storage. + @param type: bool + """ + config_dir = BaseDirectory.xdg_config_home + if not standalone: + return config_dir + return os.getenv("LEAP_CLIENT_PATH", config_dir) + + +if __name__ == "__main__": + try: + abs_prefixer = Prefixer() + except Exception as e: + assert isinstance(e, TypeError), "Something went wrong" + print "Abstract Prefixer class is working as expected" + + linux_prefixer = LinuxPrefixer() + print linux_prefixer.get_path_prefix(standalone=True) + print linux_prefixer.get_path_prefix() diff --git a/src/leap/config/provider_spec.py b/src/leap/config/provider_spec.py new file mode 100644 index 00000000..958f7846 --- /dev/null +++ b/src/leap/config/provider_spec.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +# provider_spec.py +# Copyright (C) 2013 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +leap_provider_spec = { + 'description': 'provider definition', + 'type': 'object', + 'properties': { + 'version': { + 'type': unicode, + 'default': '0.1.0' + }, + "default_language": { + 'type': unicode, + 'default': 'en' + }, + 'domain': { + 'type': unicode, # XXX define uri type + 'default': 'testprovider.example.org' + }, + 'name': { + 'type': dict, + 'format': 'translatable', + 'default': {u'en': u'Test Provider'} + }, + 'description': { + #'type': LEAPTranslatable, + 'type': dict, + 'format': 'translatable', + 'default': {u'en': u'Test provider'} + }, + 'enrollment_policy': { + 'type': unicode, # oneof ?? + 'default': 'open' + }, + 'services': { + 'type': list, # oneof ?? + 'default': ['eip'] + }, + 'api_version': { + 'type': unicode, + 'default': '0.1.0' # version regexp + }, + 'api_uri': { + 'type': unicode # uri + }, + 'public_key': { + 'type': unicode # fingerprint + }, + 'ca_cert_fingerprint': { + 'type': unicode, + }, + 'ca_cert_uri': { + 'type': unicode, + 'format': 'https-uri' + }, + 'languages': { + 'type': list, + 'default': ['en'] + } + } +} diff --git a/src/leap/config/providerconfig.py b/src/leap/config/providerconfig.py new file mode 100644 index 00000000..c3c2c298 --- /dev/null +++ b/src/leap/config/providerconfig.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- +# providerconfig.py +# Copyright (C) 2013 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +""" +Provider configuration +""" +import logging +import os + +from leap.config.baseconfig import BaseConfig, LocalizedKey +from leap.config.provider_spec import leap_provider_spec + +logger = logging.getLogger(__name__) + + +class ProviderConfig(BaseConfig): + """ + Provider configuration abstraction class + """ + + def __init__(self): + BaseConfig.__init__(self) + + def _get_spec(self): + """ + Returns the spec object for the specific configuration + """ + return leap_provider_spec + + def get_api_uri(self): + return self._safe_get_value("api_uri") + + def get_api_version(self): + return self._safe_get_value("api_version") + + def get_ca_cert_fingerprint(self): + return self._safe_get_value("ca_cert_fingerprint") + + def get_ca_cert_uri(self): + return self._safe_get_value("ca_cert_uri") + + def get_default_language(self): + return self._safe_get_value("default_language") + + @LocalizedKey + def get_description(self): + return self._safe_get_value("description") + + def get_domain(self): + return self._safe_get_value("domain") + + def get_enrollment_policy(self): + return self._safe_get_value("enrollment_policy") + + def get_languages(self): + return self._safe_get_value("languages") + + @LocalizedKey + def get_name(self): + return self._safe_get_value("name") + + def get_services(self): + return self._safe_get_value("services") + + def get_ca_cert_path(self, about_to_download=False): + """ + Returns the path to the certificate for the current provider + + @param about_to_download: defines wether we want the path to + download the cert or not. This helps avoid checking if the + cert exists because we are about to write it. + @type about_to_download: bool + """ + + cert_path = os.path.join(self.get_path_prefix(), + "leap", + "providers", + self.get_domain(), + "keys", + "ca", + "cacert.pem") + + if not about_to_download: + assert os.path.exists(cert_path), \ + "You need to download the certificate first" + logger.debug("Going to verify SSL against %s" % (cert_path,)) + + return cert_path + + def provides_eip(self): + """ + Returns True if this particular provider has the EIP + service. False otherwise + """ + return "openvpn" in self.get_services() + + +if __name__ == "__main__": + logger = logging.getLogger(name='leap') + logger.setLevel(logging.DEBUG) + console = logging.StreamHandler() + console.setLevel(logging.DEBUG) + formatter = logging.Formatter( + '%(asctime)s ' + '- %(name)s - %(levelname)s - %(message)s') + console.setFormatter(formatter) + logger.addHandler(console) + + provider = ProviderConfig() + + try: + provider.get_api_version() + except Exception as e: + assert isinstance(e, AssertionError), "Expected an assert" + print "Safe value getting is working" + + # standalone minitest + #if provider.load("provider_bad.json"): + if provider.load("leap/providers/bitmask.net/provider.json"): + print provider.get_api_version() + print provider.get_ca_cert_fingerprint() + print provider.get_ca_cert_uri() + print provider.get_default_language() + print provider.get_description() + print provider.get_description(lang="asd") + print provider.get_domain() + print provider.get_enrollment_policy() + print provider.get_languages() + print provider.get_name() + print provider.get_services() |