initial import for Open Source 🎉
This commit is contained in:
parent
1898c361f3
commit
9c0dd3b722
2048 changed files with 218743 additions and 0 deletions
62
config_app/config_util/config/TransientDirectoryProvider.py
Normal file
62
config_app/config_util/config/TransientDirectoryProvider.py
Normal file
|
@ -0,0 +1,62 @@
|
|||
import os
|
||||
|
||||
from shutil import copytree
|
||||
from backports.tempfile import TemporaryDirectory
|
||||
|
||||
from config_app.config_util.config.fileprovider import FileConfigProvider
|
||||
|
||||
OLD_CONFIG_SUBDIR = 'old/'
|
||||
|
||||
class TransientDirectoryProvider(FileConfigProvider):
|
||||
""" Implementation of the config provider that reads and writes the data
|
||||
from/to the file system, only using temporary directories,
|
||||
deleting old dirs and creating new ones as requested.
|
||||
"""
|
||||
|
||||
def __init__(self, config_volume, yaml_filename, py_filename):
|
||||
# Create a temp directory that will be cleaned up when we change the config path
|
||||
# This should ensure we have no "pollution" of different configs:
|
||||
# no uploaded config should ever affect subsequent config modifications/creations
|
||||
temp_dir = TemporaryDirectory()
|
||||
self.temp_dir = temp_dir
|
||||
self.old_config_dir = None
|
||||
super(TransientDirectoryProvider, self).__init__(temp_dir.name, yaml_filename, py_filename)
|
||||
|
||||
@property
|
||||
def provider_id(self):
|
||||
return 'transient'
|
||||
|
||||
def new_config_dir(self):
|
||||
"""
|
||||
Update the path with a new temporary directory, deleting the old one in the process
|
||||
"""
|
||||
self.temp_dir.cleanup()
|
||||
temp_dir = TemporaryDirectory()
|
||||
|
||||
self.config_volume = temp_dir.name
|
||||
self.temp_dir = temp_dir
|
||||
self.yaml_path = os.path.join(temp_dir.name, self.yaml_filename)
|
||||
|
||||
def create_copy_of_config_dir(self):
|
||||
"""
|
||||
Create a directory to store loaded/populated configuration (for rollback if necessary)
|
||||
"""
|
||||
if self.old_config_dir is not None:
|
||||
self.old_config_dir.cleanup()
|
||||
|
||||
temp_dir = TemporaryDirectory()
|
||||
self.old_config_dir = temp_dir
|
||||
|
||||
# Python 2.7's shutil.copy() doesn't allow for copying to existing directories,
|
||||
# so when copying/reading to the old saved config, we have to talk to a subdirectory,
|
||||
# and use the shutil.copytree() function
|
||||
copytree(self.config_volume, os.path.join(temp_dir.name, OLD_CONFIG_SUBDIR))
|
||||
|
||||
def get_config_dir_path(self):
|
||||
return self.config_volume
|
||||
|
||||
def get_old_config_dir(self):
|
||||
if self.old_config_dir is None:
|
||||
raise Exception('Cannot return a configuration that was no old configuration')
|
||||
|
||||
return os.path.join(self.old_config_dir.name, OLD_CONFIG_SUBDIR)
|
39
config_app/config_util/config/__init__.py
Normal file
39
config_app/config_util/config/__init__.py
Normal file
|
@ -0,0 +1,39 @@
|
|||
import base64
|
||||
import os
|
||||
|
||||
from config_app.config_util.config.fileprovider import FileConfigProvider
|
||||
from config_app.config_util.config.testprovider import TestConfigProvider
|
||||
from config_app.config_util.config.TransientDirectoryProvider import TransientDirectoryProvider
|
||||
from util.config.validator import EXTRA_CA_DIRECTORY, EXTRA_CA_DIRECTORY_PREFIX
|
||||
|
||||
|
||||
def get_config_provider(config_volume, yaml_filename, py_filename, testing=False):
|
||||
""" Loads and returns the config provider for the current environment. """
|
||||
|
||||
if testing:
|
||||
return TestConfigProvider()
|
||||
|
||||
return TransientDirectoryProvider(config_volume, yaml_filename, py_filename)
|
||||
|
||||
|
||||
def get_config_as_kube_secret(config_path):
|
||||
data = {}
|
||||
|
||||
# Kubernetes secrets don't have sub-directories, so for the extra_ca_certs dir
|
||||
# we have to put the extra certs in with a prefix, and then one of our init scripts
|
||||
# (02_get_kube_certs.sh) will expand the prefixed certs into the equivalent directory
|
||||
# so that they'll be installed correctly on startup by the certs_install script
|
||||
certs_dir = os.path.join(config_path, EXTRA_CA_DIRECTORY)
|
||||
if os.path.exists(certs_dir):
|
||||
for extra_cert in os.listdir(certs_dir):
|
||||
with open(os.path.join(certs_dir, extra_cert)) as f:
|
||||
data[EXTRA_CA_DIRECTORY_PREFIX + extra_cert] = base64.b64encode(f.read())
|
||||
|
||||
|
||||
for name in os.listdir(config_path):
|
||||
file_path = os.path.join(config_path, name)
|
||||
if not os.path.isdir(file_path):
|
||||
with open(file_path) as f:
|
||||
data[name] = base64.b64encode(f.read())
|
||||
|
||||
return data
|
72
config_app/config_util/config/basefileprovider.py
Normal file
72
config_app/config_util/config/basefileprovider.py
Normal file
|
@ -0,0 +1,72 @@
|
|||
import os
|
||||
import logging
|
||||
|
||||
from config_app.config_util.config.baseprovider import (BaseProvider, import_yaml, export_yaml,
|
||||
CannotWriteConfigException)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseFileProvider(BaseProvider):
|
||||
""" Base implementation of the config provider that reads the data from the file system. """
|
||||
|
||||
def __init__(self, config_volume, yaml_filename, py_filename):
|
||||
self.config_volume = config_volume
|
||||
self.yaml_filename = yaml_filename
|
||||
self.py_filename = py_filename
|
||||
|
||||
self.yaml_path = os.path.join(config_volume, yaml_filename)
|
||||
self.py_path = os.path.join(config_volume, py_filename)
|
||||
|
||||
def update_app_config(self, app_config):
|
||||
if os.path.exists(self.py_path):
|
||||
logger.debug('Applying config file: %s', self.py_path)
|
||||
app_config.from_pyfile(self.py_path)
|
||||
|
||||
if os.path.exists(self.yaml_path):
|
||||
logger.debug('Applying config file: %s', self.yaml_path)
|
||||
import_yaml(app_config, self.yaml_path)
|
||||
|
||||
def get_config(self):
|
||||
if not self.config_exists():
|
||||
return None
|
||||
|
||||
config_obj = {}
|
||||
import_yaml(config_obj, self.yaml_path)
|
||||
return config_obj
|
||||
|
||||
def config_exists(self):
|
||||
return self.volume_file_exists(self.yaml_filename)
|
||||
|
||||
def volume_exists(self):
|
||||
return os.path.exists(self.config_volume)
|
||||
|
||||
def volume_file_exists(self, filename):
|
||||
return os.path.exists(os.path.join(self.config_volume, filename))
|
||||
|
||||
def get_volume_file(self, filename, mode='r'):
|
||||
return open(os.path.join(self.config_volume, filename), mode=mode)
|
||||
|
||||
def get_volume_path(self, directory, filename):
|
||||
return os.path.join(directory, filename)
|
||||
|
||||
def list_volume_directory(self, path):
|
||||
dirpath = os.path.join(self.config_volume, path)
|
||||
if not os.path.exists(dirpath):
|
||||
return None
|
||||
|
||||
if not os.path.isdir(dirpath):
|
||||
return None
|
||||
|
||||
return os.listdir(dirpath)
|
||||
|
||||
def requires_restart(self, app_config):
|
||||
file_config = self.get_config()
|
||||
if not file_config:
|
||||
return False
|
||||
|
||||
for key in file_config:
|
||||
if app_config.get(key) != file_config[key]:
|
||||
return True
|
||||
|
||||
return False
|
128
config_app/config_util/config/baseprovider.py
Normal file
128
config_app/config_util/config/baseprovider.py
Normal file
|
@ -0,0 +1,128 @@
|
|||
import logging
|
||||
import yaml
|
||||
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from six import add_metaclass
|
||||
|
||||
from jsonschema import validate, ValidationError
|
||||
|
||||
from util.config.schema import CONFIG_SCHEMA
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CannotWriteConfigException(Exception):
|
||||
""" Exception raised when the config cannot be written. """
|
||||
pass
|
||||
|
||||
|
||||
class SetupIncompleteException(Exception):
|
||||
""" Exception raised when attempting to verify config that has not yet been setup. """
|
||||
pass
|
||||
|
||||
|
||||
def import_yaml(config_obj, config_file):
|
||||
with open(config_file) as f:
|
||||
c = yaml.safe_load(f)
|
||||
if not c:
|
||||
logger.debug('Empty YAML config file')
|
||||
return
|
||||
|
||||
if isinstance(c, str):
|
||||
raise Exception('Invalid YAML config file: ' + str(c))
|
||||
|
||||
for key in c.iterkeys():
|
||||
if key.isupper():
|
||||
config_obj[key] = c[key]
|
||||
|
||||
if config_obj.get('SETUP_COMPLETE', False):
|
||||
try:
|
||||
validate(config_obj, CONFIG_SCHEMA)
|
||||
except ValidationError:
|
||||
# TODO: Change this into a real error
|
||||
logger.exception('Could not validate config schema')
|
||||
else:
|
||||
logger.debug('Skipping config schema validation because setup is not complete')
|
||||
|
||||
return config_obj
|
||||
|
||||
|
||||
def get_yaml(config_obj):
|
||||
return yaml.safe_dump(config_obj, encoding='utf-8', allow_unicode=True)
|
||||
|
||||
|
||||
def export_yaml(config_obj, config_file):
|
||||
try:
|
||||
with open(config_file, 'w') as f:
|
||||
f.write(get_yaml(config_obj))
|
||||
except IOError as ioe:
|
||||
raise CannotWriteConfigException(str(ioe))
|
||||
|
||||
|
||||
@add_metaclass(ABCMeta)
|
||||
class BaseProvider(object):
|
||||
""" A configuration provider helps to load, save, and handle config override in the application.
|
||||
"""
|
||||
|
||||
@property
|
||||
def provider_id(self):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def update_app_config(self, app_config):
|
||||
""" Updates the given application config object with the loaded override config. """
|
||||
|
||||
@abstractmethod
|
||||
def get_config(self):
|
||||
""" Returns the contents of the config override file, or None if none. """
|
||||
|
||||
@abstractmethod
|
||||
def save_config(self, config_object):
|
||||
""" Updates the contents of the config override file to those given. """
|
||||
|
||||
@abstractmethod
|
||||
def config_exists(self):
|
||||
""" Returns true if a config override file exists in the config volume. """
|
||||
|
||||
@abstractmethod
|
||||
def volume_exists(self):
|
||||
""" Returns whether the config override volume exists. """
|
||||
|
||||
@abstractmethod
|
||||
def volume_file_exists(self, filename):
|
||||
""" Returns whether the file with the given name exists under the config override volume. """
|
||||
|
||||
@abstractmethod
|
||||
def get_volume_file(self, filename, mode='r'):
|
||||
""" Returns a Python file referring to the given name under the config override volume. """
|
||||
|
||||
@abstractmethod
|
||||
def write_volume_file(self, filename, contents):
|
||||
""" Writes the given contents to the config override volumne, with the given filename. """
|
||||
|
||||
@abstractmethod
|
||||
def remove_volume_file(self, filename):
|
||||
""" Removes the config override volume file with the given filename. """
|
||||
|
||||
@abstractmethod
|
||||
def list_volume_directory(self, path):
|
||||
""" Returns a list of strings representing the names of the files found in the config override
|
||||
directory under the given path. If the path doesn't exist, returns None.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def save_volume_file(self, filename, flask_file):
|
||||
""" Saves the given flask file to the config override volume, with the given
|
||||
filename.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def requires_restart(self, app_config):
|
||||
""" If true, the configuration loaded into memory for the app does not match that on disk,
|
||||
indicating that this container requires a restart.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_volume_path(self, directory, filename):
|
||||
""" Helper for constructing file paths, which may differ between providers. For example,
|
||||
kubernetes can't have subfolders in configmaps """
|
60
config_app/config_util/config/fileprovider.py
Normal file
60
config_app/config_util/config/fileprovider.py
Normal file
|
@ -0,0 +1,60 @@
|
|||
import os
|
||||
import logging
|
||||
|
||||
from config_app.config_util.config.baseprovider import export_yaml, CannotWriteConfigException
|
||||
from config_app.config_util.config.basefileprovider import BaseFileProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ensure_parent_dir(filepath):
|
||||
""" Ensures that the parent directory of the given file path exists. """
|
||||
try:
|
||||
parentpath = os.path.abspath(os.path.join(filepath, os.pardir))
|
||||
if not os.path.isdir(parentpath):
|
||||
os.makedirs(parentpath)
|
||||
except IOError as ioe:
|
||||
raise CannotWriteConfigException(str(ioe))
|
||||
|
||||
|
||||
class FileConfigProvider(BaseFileProvider):
|
||||
""" Implementation of the config provider that reads and writes the data
|
||||
from/to the file system. """
|
||||
|
||||
def __init__(self, config_volume, yaml_filename, py_filename):
|
||||
super(FileConfigProvider, self).__init__(config_volume, yaml_filename, py_filename)
|
||||
|
||||
@property
|
||||
def provider_id(self):
|
||||
return 'file'
|
||||
|
||||
def save_config(self, config_obj):
|
||||
export_yaml(config_obj, self.yaml_path)
|
||||
|
||||
def write_volume_file(self, filename, contents):
|
||||
filepath = os.path.join(self.config_volume, filename)
|
||||
_ensure_parent_dir(filepath)
|
||||
|
||||
try:
|
||||
with open(filepath, mode='w') as f:
|
||||
f.write(contents)
|
||||
except IOError as ioe:
|
||||
raise CannotWriteConfigException(str(ioe))
|
||||
|
||||
return filepath
|
||||
|
||||
def remove_volume_file(self, filename):
|
||||
filepath = os.path.join(self.config_volume, filename)
|
||||
os.remove(filepath)
|
||||
|
||||
def save_volume_file(self, filename, flask_file):
|
||||
filepath = os.path.join(self.config_volume, filename)
|
||||
_ensure_parent_dir(filepath)
|
||||
|
||||
# Write the file.
|
||||
try:
|
||||
flask_file.save(filepath)
|
||||
except IOError as ioe:
|
||||
raise CannotWriteConfigException(str(ioe))
|
||||
|
||||
return filepath
|
75
config_app/config_util/config/test/test_helpers.py
Normal file
75
config_app/config_util/config/test/test_helpers.py
Normal file
|
@ -0,0 +1,75 @@
|
|||
import pytest
|
||||
import os
|
||||
import base64
|
||||
|
||||
from backports.tempfile import TemporaryDirectory
|
||||
|
||||
from config_app.config_util.config import get_config_as_kube_secret
|
||||
from util.config.validator import EXTRA_CA_DIRECTORY
|
||||
|
||||
|
||||
def _create_temp_file_structure(file_structure):
|
||||
temp_dir = TemporaryDirectory()
|
||||
|
||||
for filename, data in file_structure.iteritems():
|
||||
if filename == EXTRA_CA_DIRECTORY:
|
||||
extra_ca_dir_path = os.path.join(temp_dir.name, EXTRA_CA_DIRECTORY)
|
||||
os.mkdir(extra_ca_dir_path)
|
||||
|
||||
for name, cert_value in data:
|
||||
with open(os.path.join(extra_ca_dir_path, name), 'w') as f:
|
||||
f.write(cert_value)
|
||||
else:
|
||||
with open(os.path.join(temp_dir.name, filename), 'w') as f:
|
||||
f.write(data)
|
||||
|
||||
return temp_dir
|
||||
|
||||
|
||||
@pytest.mark.parametrize('file_structure, expected_secret', [
|
||||
pytest.param({
|
||||
'config.yaml': 'test:true',
|
||||
},
|
||||
{
|
||||
'config.yaml': 'dGVzdDp0cnVl',
|
||||
}, id='just a config value'),
|
||||
pytest.param({
|
||||
'config.yaml': 'test:true',
|
||||
'otherfile.ext': 'im a file'
|
||||
},
|
||||
{
|
||||
'config.yaml': 'dGVzdDp0cnVl',
|
||||
'otherfile.ext': base64.b64encode('im a file')
|
||||
}, id='config and another file'),
|
||||
pytest.param({
|
||||
'config.yaml': 'test:true',
|
||||
'extra_ca_certs': [
|
||||
('cert.crt', 'im a cert!'),
|
||||
]
|
||||
},
|
||||
{
|
||||
'config.yaml': 'dGVzdDp0cnVl',
|
||||
'extra_ca_certs_cert.crt': base64.b64encode('im a cert!'),
|
||||
}, id='config and an extra cert'),
|
||||
pytest.param({
|
||||
'config.yaml': 'test:true',
|
||||
'otherfile.ext': 'im a file',
|
||||
'extra_ca_certs': [
|
||||
('cert.crt', 'im a cert!'),
|
||||
('another.crt', 'im a different cert!'),
|
||||
]
|
||||
},
|
||||
{
|
||||
'config.yaml': 'dGVzdDp0cnVl',
|
||||
'otherfile.ext': base64.b64encode('im a file'),
|
||||
'extra_ca_certs_cert.crt': base64.b64encode('im a cert!'),
|
||||
'extra_ca_certs_another.crt': base64.b64encode('im a different cert!'),
|
||||
}, id='config, files, and extra certs!'),
|
||||
])
|
||||
def test_get_config_as_kube_secret(file_structure, expected_secret):
|
||||
temp_dir = _create_temp_file_structure(file_structure)
|
||||
|
||||
secret = get_config_as_kube_secret(temp_dir.name)
|
||||
assert secret == expected_secret
|
||||
|
||||
temp_dir.cleanup()
|
|
@ -0,0 +1,68 @@
|
|||
import pytest
|
||||
import os
|
||||
|
||||
from config_app.config_util.config.TransientDirectoryProvider import TransientDirectoryProvider
|
||||
|
||||
|
||||
@pytest.mark.parametrize('files_to_write, operations, expected_new_dir', [
|
||||
pytest.param({
|
||||
'config.yaml': 'a config',
|
||||
}, ([], [], []), {
|
||||
'config.yaml': 'a config',
|
||||
}, id='just a config'),
|
||||
pytest.param({
|
||||
'config.yaml': 'a config',
|
||||
'oldfile': 'hmmm'
|
||||
}, ([], [], ['oldfile']), {
|
||||
'config.yaml': 'a config',
|
||||
}, id='delete a file'),
|
||||
pytest.param({
|
||||
'config.yaml': 'a config',
|
||||
'oldfile': 'hmmm'
|
||||
}, ([('newfile', 'asdf')], [], ['oldfile']), {
|
||||
'config.yaml': 'a config',
|
||||
'newfile': 'asdf'
|
||||
}, id='delete and add a file'),
|
||||
pytest.param({
|
||||
'config.yaml': 'a config',
|
||||
'somefile': 'before'
|
||||
}, ([('newfile', 'asdf')], [('somefile', 'after')], []), {
|
||||
'config.yaml': 'a config',
|
||||
'newfile': 'asdf',
|
||||
'somefile': 'after',
|
||||
}, id='add new files and change files'),
|
||||
])
|
||||
def test_transient_dir_copy_config_dir(files_to_write, operations, expected_new_dir):
|
||||
config_provider = TransientDirectoryProvider('', '', '')
|
||||
|
||||
for name, data in files_to_write.iteritems():
|
||||
config_provider.write_volume_file(name, data)
|
||||
|
||||
config_provider.create_copy_of_config_dir()
|
||||
|
||||
for create in operations[0]:
|
||||
(name, data) = create
|
||||
config_provider.write_volume_file(name, data)
|
||||
|
||||
for update in operations[1]:
|
||||
(name, data) = update
|
||||
config_provider.write_volume_file(name, data)
|
||||
|
||||
for delete in operations[2]:
|
||||
config_provider.remove_volume_file(delete)
|
||||
|
||||
# check that the new directory matches expected state
|
||||
for filename, data in expected_new_dir.iteritems():
|
||||
with open(os.path.join(config_provider.get_config_dir_path(), filename)) as f:
|
||||
new_data = f.read()
|
||||
assert new_data == data
|
||||
|
||||
# Now check that the old dir matches the original state
|
||||
saved = config_provider.get_old_config_dir()
|
||||
|
||||
for filename, data in files_to_write.iteritems():
|
||||
with open(os.path.join(saved, filename)) as f:
|
||||
new_data = f.read()
|
||||
assert new_data == data
|
||||
|
||||
config_provider.temp_dir.cleanup()
|
83
config_app/config_util/config/testprovider.py
Normal file
83
config_app/config_util/config/testprovider.py
Normal file
|
@ -0,0 +1,83 @@
|
|||
import json
|
||||
import io
|
||||
import os
|
||||
|
||||
from config_app.config_util.config.baseprovider import BaseProvider
|
||||
|
||||
REAL_FILES = ['test/data/signing-private.gpg', 'test/data/signing-public.gpg', 'test/data/test.pem']
|
||||
|
||||
|
||||
class TestConfigProvider(BaseProvider):
|
||||
""" Implementation of the config provider for testing. Everything is kept in-memory instead on
|
||||
the real file system. """
|
||||
|
||||
def __init__(self):
|
||||
self.clear()
|
||||
|
||||
def clear(self):
|
||||
self.files = {}
|
||||
self._config = {}
|
||||
|
||||
@property
|
||||
def provider_id(self):
|
||||
return 'test'
|
||||
|
||||
def update_app_config(self, app_config):
|
||||
self._config = app_config
|
||||
|
||||
def get_config(self):
|
||||
if not 'config.yaml' in self.files:
|
||||
return None
|
||||
|
||||
return json.loads(self.files.get('config.yaml', '{}'))
|
||||
|
||||
def save_config(self, config_obj):
|
||||
self.files['config.yaml'] = json.dumps(config_obj)
|
||||
|
||||
def config_exists(self):
|
||||
return 'config.yaml' in self.files
|
||||
|
||||
def volume_exists(self):
|
||||
return True
|
||||
|
||||
def volume_file_exists(self, filename):
|
||||
if filename in REAL_FILES:
|
||||
return True
|
||||
|
||||
return filename in self.files
|
||||
|
||||
def save_volume_file(self, filename, flask_file):
|
||||
self.files[filename] = flask_file.read()
|
||||
|
||||
def write_volume_file(self, filename, contents):
|
||||
self.files[filename] = contents
|
||||
|
||||
def get_volume_file(self, filename, mode='r'):
|
||||
if filename in REAL_FILES:
|
||||
return open(filename, mode=mode)
|
||||
|
||||
return io.BytesIO(self.files[filename])
|
||||
|
||||
def remove_volume_file(self, filename):
|
||||
self.files.pop(filename, None)
|
||||
|
||||
def list_volume_directory(self, path):
|
||||
paths = []
|
||||
for filename in self.files:
|
||||
if filename.startswith(path):
|
||||
paths.append(filename[len(path) + 1:])
|
||||
|
||||
return paths
|
||||
|
||||
def requires_restart(self, app_config):
|
||||
return False
|
||||
|
||||
def reset_for_test(self):
|
||||
self._config['SUPER_USERS'] = ['devtable']
|
||||
self.files = {}
|
||||
|
||||
def get_volume_path(self, directory, filename):
|
||||
return os.path.join(directory, filename)
|
||||
|
||||
def get_config_dir_path(self):
|
||||
return ''
|
Reference in a new issue