docker.images/ansible.awx/awx-17.1.0/awx/conf/settings.py

532 lines
22 KiB
Python

# 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__