# Python import contextlib import logging import sys import threading import time import os # Django from django.conf import LazySettings from django.conf import settings, UserSettingsHolder from django.core.cache import cache as django_cache from django.core.exceptions import ImproperlyConfigured from django.db import transaction, connection from django.db.utils import Error as DBError, ProgrammingError from django.utils.functional import cached_property # Django REST Framework from rest_framework.fields import empty, SkipField import cachetools # Tower from awx.main.utils import encrypt_field, decrypt_field from awx.conf import settings_registry from awx.conf.models import Setting from awx.conf.migrations._reencrypt import decrypt_field as old_decrypt_field # FIXME: Gracefully handle when settings are accessed before the database is # ready (or during migrations). logger = logging.getLogger('awx.conf.settings') SETTING_MEMORY_TTL = 5 if 'callback_receiver' in ' '.join(sys.argv) else 0 # Store a special value to indicate when a setting is not set in the database. SETTING_CACHE_NOTSET = '___notset___' # Cannot store None in cache; use a special value instead to indicate None. # If the special value for None is the same as the "not set" value, then a value # of None will be equivalent to the setting not being set (and will raise an # AttributeError if there is no other default defined). # SETTING_CACHE_NONE = '___none___' SETTING_CACHE_NONE = SETTING_CACHE_NOTSET # Cannot store empty list/tuple in cache; use a special value instead to # indicate an empty list. SETTING_CACHE_EMPTY_LIST = '___[]___' # Cannot store empty dict in cache; use a special value instead to indicate # an empty dict. SETTING_CACHE_EMPTY_DICT = '___{}___' # Expire settings from cache after this many seconds. SETTING_CACHE_TIMEOUT = 60 # Flag indicating whether to store field default values in the cache. SETTING_CACHE_DEFAULTS = True __all__ = ['SettingsWrapper', 'get_settings_to_cache', 'SETTING_CACHE_NOTSET'] @contextlib.contextmanager def _ctit_db_wrapper(trans_safe=False): ''' Wrapper to avoid undesired actions by Django ORM when managing settings if only getting a setting, can use trans_safe=True, which will avoid throwing errors if the prior context was a broken transaction. Any database errors will be logged, but exception will be suppressed. ''' rollback_set = None is_atomic = None try: if trans_safe: is_atomic = connection.in_atomic_block if is_atomic: rollback_set = transaction.get_rollback() if rollback_set: logger.debug('Obtaining database settings in spite of broken transaction.') transaction.set_rollback(False) yield except DBError as exc: if trans_safe: if 'migrate' not in sys.argv and 'check_migrations' not in sys.argv: level = logger.exception if isinstance(exc, ProgrammingError): if 'relation' in str(exc) and 'does not exist' in str(exc): # this generally means we can't fetch Tower configuration # because the database hasn't actually finished migrating yet; # this is usually a sign that a service in a container (such as ws_broadcast) # has come up *before* the database has finished migrating, and # especially that the conf.settings table doesn't exist yet level = logger.debug level('Database settings are not available, using defaults.') else: logger.exception('Error modifying something related to database settings.') finally: if trans_safe and is_atomic and rollback_set: transaction.set_rollback(rollback_set) def filter_sensitive(registry, key, value): if registry.is_setting_encrypted(key): return '$encrypted$' return value class TransientSetting(object): __slots__ = ('pk', 'value') def __init__(self, pk, value): self.pk = pk self.value = value class EncryptedCacheProxy(object): def __init__(self, cache, registry, encrypter=None, decrypter=None): """ This proxy wraps a Django cache backend and overwrites the `get`/`set`/`set_many` methods to handle field encryption/decryption for sensitive values. :param cache: the Django cache backend to proxy to :param registry: the settings registry instance used to determine if a field is encrypted or not. :param encrypter: a callable used to encrypt field values; defaults to ``awx.main.utils.encrypt_field`` :param decrypter: a callable used to decrypt field values; defaults to ``awx.main.utils.decrypt_field`` """ # These values have to be stored via self.__dict__ in this way to get # around the magic __setattr__ method on this class. self.__dict__['cache'] = cache self.__dict__['registry'] = registry self.__dict__['encrypter'] = encrypter or encrypt_field self.__dict__['decrypter'] = decrypter or decrypt_field def get(self, key, **kwargs): value = self.cache.get(key, **kwargs) value = self._handle_encryption(self.decrypter, key, value) return value def set(self, key, value, log=True, **kwargs): if log is True: logger.debug('cache set(%r, %r, %r)', key, filter_sensitive(self.registry, key, value), SETTING_CACHE_TIMEOUT) self.cache.set( key, self._handle_encryption(self.encrypter, key, value), **kwargs ) def set_many(self, data, **kwargs): filtered_data = dict( (key, filter_sensitive(self.registry, key, value)) for key, value in data.items() ) logger.debug('cache set_many(%r, %r)', filtered_data, SETTING_CACHE_TIMEOUT) for key, value in data.items(): self.set(key, value, log=False, **kwargs) def _handle_encryption(self, method, key, value): if value is not empty and self.registry.is_setting_encrypted(key): # If the setting exists in the database, we'll use its primary key # as part of the AES key when encrypting/decrypting obj_id = self.cache.get(Setting.get_cache_id_key(key), default=empty) if obj_id is empty: logger.info('Efficiency notice: Corresponding id not stored in cache %s', Setting.get_cache_id_key(key)) obj_id = getattr(self._get_setting_from_db(key), 'pk', None) elif obj_id == SETTING_CACHE_NONE: obj_id = None return method( TransientSetting( pk=obj_id, value=value ), 'value' ) # If the field in question isn't an "encrypted" field, this function is # a no-op; it just returns the provided value return value def _get_setting_from_db(self, key): field = self.registry.get_setting_field(key) if not field.read_only: return Setting.objects.filter(key=key, user__isnull=True).order_by('pk').first() def __getattr__(self, name): return getattr(self.cache, name) def __setattr__(self, name, value): setattr(self.cache, name, value) def get_writeable_settings(registry): return registry.get_registered_settings(read_only=False) def get_settings_to_cache(registry): return dict([(key, SETTING_CACHE_NOTSET) for key in get_writeable_settings(registry)]) def get_cache_value(value): '''Returns the proper special cache setting for a value based on instance type. ''' if value is None: value = SETTING_CACHE_NONE elif isinstance(value, (list, tuple)) and len(value) == 0: value = SETTING_CACHE_EMPTY_LIST elif isinstance(value, (dict,)) and len(value) == 0: value = SETTING_CACHE_EMPTY_DICT return value class SettingsWrapper(UserSettingsHolder): @classmethod def initialize(cls, cache=None, registry=None): """ Used to initialize and wrap the Django settings context. :param cache: the Django cache backend to use for caching setting values. ``django.core.cache`` is used by default. :param registry: the settings registry instance used. The global ``awx.conf.settings_registry`` is used by default. """ if not getattr(settings, '_awx_conf_settings', False): settings_wrapper = cls( settings._wrapped, cache=cache or django_cache, registry=registry or settings_registry ) settings._wrapped = settings_wrapper def __init__(self, default_settings, cache, registry): """ This constructor is generally not called directly, but by ``SettingsWrapper.initialize`` at app startup time when settings are parsed. """ # These values have to be stored via self.__dict__ in this way to get # around the magic __setattr__ method on this class (which is used to # store API-assigned settings in the database). self.__dict__['__forks__'] = {} self.__dict__['default_settings'] = default_settings self.__dict__['_awx_conf_settings'] = self self.__dict__['_awx_conf_preload_expires'] = None self.__dict__['_awx_conf_preload_lock'] = threading.RLock() self.__dict__['_awx_conf_init_readonly'] = False self.__dict__['cache'] = EncryptedCacheProxy(cache, registry) self.__dict__['registry'] = registry # record the current pid so we compare it post-fork for # processes like the dispatcher and callback receiver self.__dict__['pid'] = os.getpid() def __clean_on_fork__(self): pid = os.getpid() # if the current pid does *not* match the value on self, it means # that value was copied on fork, and we're now in a *forked* process; # the *first* time we enter this code path (on setting access), # forcibly close DB/cache sockets and set a marker so we don't run # this code again _in this process_ # if pid != self.__dict__['pid'] and pid not in self.__dict__['__forks__']: self.__dict__['__forks__'][pid] = True # It's important to close these post-fork, because we # don't want the forked processes to inherit the open sockets # for the DB and cache connections (that way lies race conditions) connection.close() django_cache.close() @cached_property def all_supported_settings(self): return self.registry.get_registered_settings() def _preload_cache(self): # Ensure we're only modifying local preload timeout from one thread. with self._awx_conf_preload_lock: # If local preload timeout has not expired, skip preloading. if self._awx_conf_preload_expires and self._awx_conf_preload_expires > time.time(): return # Otherwise update local preload timeout. self.__dict__['_awx_conf_preload_expires'] = time.time() + SETTING_CACHE_TIMEOUT # Check for any settings that have been defined in Python files and # make those read-only to avoid overriding in the database. if not self._awx_conf_init_readonly: defaults_snapshot = self._get_default('DEFAULTS_SNAPSHOT') for key in get_writeable_settings(self.registry): init_default = defaults_snapshot.get(key, None) try: file_default = self._get_default(key) except AttributeError: file_default = None if file_default != init_default and file_default is not None: logger.debug('Setting %s has been marked read-only!', key) self.registry._registry[key]['read_only'] = True self.registry._registry[key]['defined_in_file'] = True self.__dict__['_awx_conf_init_readonly'] = True # If local preload timer has expired, check to see if another process # has already preloaded the cache and skip preloading if so. if self.cache.get('_awx_conf_preload_expires', default=empty) is not empty: return # Initialize all database-configurable settings with a marker value so # to indicate from the cache that the setting is not configured without # a database lookup. settings_to_cache = get_settings_to_cache(self.registry) setting_ids = {} # Load all settings defined in the database. for setting in Setting.objects.filter(key__in=settings_to_cache.keys(), user__isnull=True).order_by('pk'): if settings_to_cache[setting.key] != SETTING_CACHE_NOTSET: continue if self.registry.is_setting_encrypted(setting.key): setting_ids[setting.key] = setting.id try: value = decrypt_field(setting, 'value') except ValueError as e: #TODO: Remove in Tower 3.3 logger.debug('encountered error decrypting field: %s - attempting fallback to old', e) value = old_decrypt_field(setting, 'value') else: value = setting.value settings_to_cache[setting.key] = get_cache_value(value) # Load field default value for any settings not found in the database. if SETTING_CACHE_DEFAULTS: for key, value in settings_to_cache.items(): if value != SETTING_CACHE_NOTSET: continue field = self.registry.get_setting_field(key) try: settings_to_cache[key] = get_cache_value(field.get_default()) if self.registry.is_setting_encrypted(key): # No database pk, so None will be passed to encryption algorithm setting_ids[key] = SETTING_CACHE_NOTSET except SkipField: pass # Generate a cache key for each setting and store them all at once. settings_to_cache = dict([(Setting.get_cache_key(k), v) for k, v in settings_to_cache.items()]) for k, id_val in setting_ids.items(): logger.debug('Saving id in cache for encrypted setting %s, %s', Setting.get_cache_id_key(k), id_val) self.cache.cache.set(Setting.get_cache_id_key(k), id_val) settings_to_cache['_awx_conf_preload_expires'] = self._awx_conf_preload_expires self.cache.set_many(settings_to_cache, timeout=SETTING_CACHE_TIMEOUT) def _get_local(self, name, validate=True): self.__clean_on_fork__() self._preload_cache() cache_key = Setting.get_cache_key(name) try: cache_value = self.cache.get(cache_key, default=empty) except ValueError: cache_value = empty if cache_value == SETTING_CACHE_NOTSET: value = empty elif cache_value == SETTING_CACHE_NONE: value = None elif cache_value == SETTING_CACHE_EMPTY_LIST: value = [] elif cache_value == SETTING_CACHE_EMPTY_DICT: value = {} else: value = cache_value field = self.registry.get_setting_field(name) if value is empty: setting = None setting_id = None if not field.read_only or name in ( # these values are read-only - however - we *do* want # to fetch their value from the database 'INSTALL_UUID', 'AWX_ISOLATED_PRIVATE_KEY', 'AWX_ISOLATED_PUBLIC_KEY', ): setting = Setting.objects.filter(key=name, user__isnull=True).order_by('pk').first() if setting: if getattr(field, 'encrypted', False): value = decrypt_field(setting, 'value') setting_id = setting.id else: value = setting.value else: value = SETTING_CACHE_NOTSET if SETTING_CACHE_DEFAULTS: try: value = field.get_default() if getattr(field, 'encrypted', False): setting_id = SETTING_CACHE_NONE except SkipField: pass # If None implies not set, convert when reading the value. if value is None and SETTING_CACHE_NOTSET == SETTING_CACHE_NONE: value = SETTING_CACHE_NOTSET if cache_value != value: if setting_id: logger.debug('Saving id in cache for encrypted setting %s', cache_key) self.cache.cache.set(Setting.get_cache_id_key(cache_key), setting_id) self.cache.set(cache_key, get_cache_value(value), timeout=SETTING_CACHE_TIMEOUT) if value == SETTING_CACHE_NOTSET and not SETTING_CACHE_DEFAULTS: try: value = field.get_default() except SkipField: pass if value not in (empty, SETTING_CACHE_NOTSET): try: if field.read_only: internal_value = field.to_internal_value(value) field.run_validators(internal_value) return internal_value else: if validate: return field.run_validation(value) else: return value except Exception: logger.warning( 'The current value "%r" for setting "%s" is invalid.', value, name, exc_info=True) return empty def _get_default(self, name): return getattr(self.default_settings, name) @property def SETTINGS_MODULE(self): return self._get_default('SETTINGS_MODULE') @cachetools.cached(cache=cachetools.TTLCache(maxsize=2048, ttl=SETTING_MEMORY_TTL)) def __getattr__(self, name): value = empty if name in self.all_supported_settings: with _ctit_db_wrapper(trans_safe=True): value = self._get_local(name) if value is not empty: return value return self._get_default(name) def _set_local(self, name, value): field = self.registry.get_setting_field(name) if field.read_only: logger.warning('Attempt to set read only setting "%s".', name) raise ImproperlyConfigured('Setting "{}" is read only.'.format(name)) try: data = field.to_representation(value) setting_value = field.run_validation(data) db_value = field.to_representation(setting_value) except Exception as e: logger.exception('Unable to assign value "%r" to setting "%s".', value, name, exc_info=True) raise e setting = Setting.objects.filter(key=name, user__isnull=True).order_by('pk').first() if not setting: setting = Setting.objects.create(key=name, user=None, value=db_value) # post_save handler will delete from cache when added. elif setting.value != db_value or type(setting.value) != type(db_value): setting.value = db_value setting.save(update_fields=['value']) # post_save handler will delete from cache when changed. def __setattr__(self, name, value): if name in self.all_supported_settings: with _ctit_db_wrapper(): self._set_local(name, value) else: setattr(self.default_settings, name, value) def _del_local(self, name): field = self.registry.get_setting_field(name) if field.read_only: logger.warning('Attempt to delete read only setting "%s".', name) raise ImproperlyConfigured('Setting "{}" is read only.'.format(name)) for setting in Setting.objects.filter(key=name, user__isnull=True): setting.delete() # pre_delete handler will delete from cache. def __delattr__(self, name): if name in self.all_supported_settings: with _ctit_db_wrapper(): self._del_local(name) else: delattr(self.default_settings, name) def __dir__(self): keys = [] with _ctit_db_wrapper(trans_safe=True): for setting in Setting.objects.filter( key__in=self.all_supported_settings, user__isnull=True): # Skip returning settings that have been overridden but are # considered to be "not set". if setting.value is None and SETTING_CACHE_NOTSET == SETTING_CACHE_NONE: continue if setting.key not in keys: keys.append(str(setting.key)) for key in dir(self.default_settings): if key not in keys: keys.append(key) return keys def is_overridden(self, setting): set_locally = False if setting in self.all_supported_settings: with _ctit_db_wrapper(trans_safe=True): set_locally = Setting.objects.filter(key=setting, user__isnull=True).exists() set_on_default = getattr(self.default_settings, 'is_overridden', lambda s: False)(setting) return (set_locally or set_on_default) def __getattr_without_cache__(self, name): # Django 1.10 added an optimization to settings lookup: # https://code.djangoproject.com/ticket/27625 # https://github.com/django/django/commit/c1b221a9b913315998a1bcec2f29a9361a74d1ac # This change caches settings lookups on the __dict__ of the LazySettings # object, which is not okay to do in an environment where settings can # change in-process (the entire point of awx's custom settings implementation) # This restores the original behavior that *does not* cache. if self._wrapped is empty: self._setup(name) return getattr(self._wrapped, name) LazySettings.__getattr__ = __getattr_without_cache__