initial import for Open Source 🎉

This commit is contained in:
Jimmy Zelinskie 2019-11-12 11:09:47 -05:00
parent 1898c361f3
commit 9c0dd3b722
2048 changed files with 218743 additions and 0 deletions

View 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)

View 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

View 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

View 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 """

View 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

View 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()

View file

@ -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()

View 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 ''