From 6c6ef2bb408eb4a392e95fb810933453bf88c980 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Tue, 14 Jul 2015 18:04:43 -0700 Subject: [PATCH] Started implementation of Apache base --- setup.py | 2 - tests/__init__.py | 2 +- tests/compatibility/Dockerfile | 2 +- tests/compatibility/__init__.py | 2 +- tests/compatibility/configurators/__init__.py | 1 + .../configurators/apache/__init__.py | 1 + .../configurators/apache/common.py | 34 +++++++ tests/compatibility/configurators/common.py | 95 +++++++++++++++++++ tests/compatibility/errors.py | 5 + tests/compatibility/interfaces.py | 53 +++++++++++ tests/compatibility/parser.py | 49 ---------- tests/compatibility/plugin_test.py | 77 +++++++++++++-- tests/compatibility/util.py | 44 ++++++--- tests/setup.py | 23 +++++ 14 files changed, 318 insertions(+), 72 deletions(-) create mode 100644 tests/compatibility/configurators/__init__.py create mode 100644 tests/compatibility/configurators/apache/__init__.py create mode 100644 tests/compatibility/configurators/apache/common.py create mode 100644 tests/compatibility/configurators/common.py create mode 100644 tests/compatibility/errors.py create mode 100644 tests/compatibility/interfaces.py delete mode 100644 tests/compatibility/parser.py create mode 100644 tests/setup.py diff --git a/setup.py b/setup.py index d054303dc..1e0d58a70 100644 --- a/setup.py +++ b/setup.py @@ -60,7 +60,6 @@ docs_extras = [ testing_extras = [ 'coverage', - 'docker-py', 'nose', 'nosexcover', 'tox', @@ -107,7 +106,6 @@ setup( entry_points={ 'console_scripts': [ - 'compatibility = tests.compatibility.plugin_test:main [testing]', 'letsencrypt = letsencrypt.cli:main', 'letsencrypt-renewer = letsencrypt.renewer:main', ], diff --git a/tests/__init__.py b/tests/__init__.py index d9db68022..ea250d700 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -"""Let's Encrypt Tests""" +"""Let's Encrypt tests""" diff --git a/tests/compatibility/Dockerfile b/tests/compatibility/Dockerfile index 93541408f..6637b9e14 100644 --- a/tests/compatibility/Dockerfile +++ b/tests/compatibility/Dockerfile @@ -15,6 +15,6 @@ ENV APACHE_CONFDIR=/tmp/apache2 \ APACHE_LOCK_DIR=/var/lock \ APACHE_LOG_DIR=/usr/local/apache2/logs -COPY a2enmod.sh /usr/local/bin/ +COPY tests/compatibility/a2enmod.sh /usr/local/bin/ CMD [ "httpd-foreground" ] diff --git a/tests/compatibility/__init__.py b/tests/compatibility/__init__.py index ebf30d2b8..90807863a 100644 --- a/tests/compatibility/__init__.py +++ b/tests/compatibility/__init__.py @@ -1 +1 @@ -"""Let's Encrypt Compatibility Test""" +"""Let's Encrypt compatibility test""" diff --git a/tests/compatibility/configurators/__init__.py b/tests/compatibility/configurators/__init__.py new file mode 100644 index 000000000..bf7b3471f --- /dev/null +++ b/tests/compatibility/configurators/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt compatibility test configurators""" diff --git a/tests/compatibility/configurators/apache/__init__.py b/tests/compatibility/configurators/apache/__init__.py new file mode 100644 index 000000000..9feca23d4 --- /dev/null +++ b/tests/compatibility/configurators/apache/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt compatibility test Apache configurators""" diff --git a/tests/compatibility/configurators/apache/common.py b/tests/compatibility/configurators/apache/common.py new file mode 100644 index 000000000..9321981d0 --- /dev/null +++ b/tests/compatibility/configurators/apache/common.py @@ -0,0 +1,34 @@ +"""Provides a common base for Apache tests""" +import mock + +from tests.compatibilty import configurators + +class ApacheConfiguratorCommonTester(configurators.common.ConfiguratorTester): + """A common base for Apache test configurators""" + + def __init__(self, args): + """Initializes the plugin with the given command line args""" + super(ApacheConfiguratorCommonTester, self).__init__(args) + self._patch = mock.patch('letsencrypt_apache.configurator.subprocess') + self._mock = self._patch.start() + self._mock.check_call = self._check_call + self._apache_configurator = None + + def __getattr__(self, name): + """Wraps the Apache Configurator methods""" + method = getattr(self._apache_configurator, name, None) + if callable(method): + return method + else: + raise AttributeError() + + def _check_call(self, command, *args, **kwargs): + """A function to mock the call to subprocess.check_call""" + + def load_config(self): + """Loads the next configuration for the plugin to test""" + raise NotImplementedError() + + def get_test_domain_names(self): + """Returns a list of domain names to test against the plugin""" + raise NotImplementedError() diff --git a/tests/compatibility/configurators/common.py b/tests/compatibility/configurators/common.py new file mode 100644 index 000000000..935190dd9 --- /dev/null +++ b/tests/compatibility/configurators/common.py @@ -0,0 +1,95 @@ +"""Provides a common base for compatibility test configurators""" +import logging +import multiprocessing +import os + +import docker + +from tests.compatibility import errors +from tests.compatibility import util + + +logger = logging.getLogger(__name__) + + +class ConfiguratorTester(object): + # pylint: disable=too-many-instance-attributes + """A common base for compatibility test configurators""" + + _NOT_ADDED_ARGS = True + + @classmethod + def add_parser_arguments(cls, parser): + """Adds command line arguments needed by the plugin""" + if ConfiguratorTester._NOT_ADDED_ARGS: + group = parser.add_argument_group('docker') + group.add_argument( + '--docker-url', default='unix://var/run/docker.sock', + help='URL of the docker server') + group.add_argument( + '--no-remove', action='store_true', + help='do not delete container on program exit') + ConfiguratorTester._NOT_ADDED_ARGS = False + + def __init__(self, args): + """Initializes the plugin with the given command line args""" + self.temp_dir = util.setup_temp_dir(args.configs) + self.config_dir = os.path.join(self.temp_dir, util.CONFIG_DIR) + self._configs = os.listdir(self.config_dir) + + self.args = args + self._docker_client = docker.Client( + base_url=self.args.docker_url, version='auto') + self.http_port, self.https_port = util.get_two_free_ports() + self._container_id = self._log_process = None + + def has_more_configs(self): + """Returns true if there are more configs to test""" + return bool(self._configs) + + def cleanup_from_tests(self): + """Performs any necessary cleanup from running plugin tests""" + self._docker_client.stop(self._container_id) + self._log_process.join() + if not self.args.no_remove: + self._docker_client.remove_container(self._container_id) + + def get_next_config(self): + """Returns the next config directory to be tested""" + return self._configs.pop() + + def start_docker(self, image_name): + """Creates and runs a Docker container with the specified image""" + for line in self._docker_client.pull(image_name, stream=True): + logger.debug(line) + + host_config = docker.utils.create_host_config( + binds={ + self.config_dir : {'bind' : self.config_dir, 'mode' : 'rw'}}, + port_bindings={ + 80 : ('127.0.0.1', self.http_port), + 443 : ('127.0.0.1', self.https_port)},) + container = self._docker_client.create_container( + image_name, ports=[80, 443], volumes=self.config_dir, + host_config=host_config) + if container['Warnings']: + logger.warning(container['Warnings']) + self._container_id = container['Id'] + self._docker_client.start(self._container_id) + + self._log_process = multiprocessing.Process( + target=self._start_log_thread) + self._log_process.start() + + def execute_in_docker(self, command): + """Executes command inside the running docker image""" + exec_id = self._docker_client.exec_create(self._container_id, command) + output = self._docker_client.exec_start(exec_id) + if self._docker_client.exec_inspect(exec_id)['ExitCode']: + raise errors.Error('Docker command \'{0}\' failed'.format(command)) + return output + + def _start_log_thread(self): + client = docker.Client(base_url=self.args.docker_url, version='auto') + for line in client.logs(self._container_id, stream=True): + logger.debug(line) diff --git a/tests/compatibility/errors.py b/tests/compatibility/errors.py new file mode 100644 index 000000000..3b7eb6911 --- /dev/null +++ b/tests/compatibility/errors.py @@ -0,0 +1,5 @@ +"""Let's Encrypt compatibility test errors""" + + +class Error(Exception): + """Generic Let's Encrypt compatibility test error""" diff --git a/tests/compatibility/interfaces.py b/tests/compatibility/interfaces.py new file mode 100644 index 000000000..035a9f541 --- /dev/null +++ b/tests/compatibility/interfaces.py @@ -0,0 +1,53 @@ +"""Let's Encrypt compatibility test interfaces""" +import zope.interface + +import letsencrypt.interfaces + + +class IPluginTester(zope.interface.Interface): + """Wraps a Let's Encrypt plugin""" + @classmethod + def add_parser_arguments(cls, parser): + """Adds command line arguments needed by the parser""" + + def __init__(self, args): + """Initializes the plugin with the given command line args""" + + def cleanup_from_tests(self): + """Performs any necessary cleanup from running plugin tests. + + This is guarenteed to be called before the program exits. + + """ + + def has_more_configs(self): + """Returns True if there are more configs to test""" + + def load_config(self): + """Loads the next configuration for the plugin to test""" + + +class IConfiguratorBaseTester(IPluginTester): + """Common functionality for authenticator/installer tests""" + http_port = zope.interface.Attribute( + 'The port to connect to on localhost for HTTP traffic') + + https_port = zope.interface.Attribute( + 'The port to connect to on localhost for HTTPS traffic') + + def get_test_domain_names(self): + """Returns a list of domain names to test against the plugin""" + + +class IAuthenticatorTester( + IConfiguratorBaseTester, letsencrypt.interfaces.IAuthenticator): + """Wraps a Let's Encrypt authenticator""" + + +class IInstallerTester( + IConfiguratorBaseTester, letsencrypt.interfaces.IInstaller): + """Wraps a Let's Encrypt installer""" + + +class IConfiguratorTester(IAuthenticatorTester, IInstallerTester): + """Wraps a Let's Encrypt configurator""" diff --git a/tests/compatibility/parser.py b/tests/compatibility/parser.py deleted file mode 100644 index 5946f15be..000000000 --- a/tests/compatibility/parser.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Module for parsing command line arguments and config files.""" -import argparse - - -DESCRIPTION = """ -Tests Let's Encrypt plugins against different web servers and configurations -using Docker images. It is assumed that Docker is already installed. - -""" - -def parse_args(): - """Returns parsed command line arguments.""" - parser = argparse.ArgumentParser(description=DESCRIPTION) - - add_args(parser) - args = parser.parse_args() - - if args.redirect: - args.names = True - args.install = True - elif args.install: - args.names = True - - return args - - -def add_args(parser): - """Adds general/program wide arguments to the group.""" - group = parser.add_argument_group("general") - group.add_argument( - "-t", "--tar", default="configs.tar.gz", - help="a gzipped tarball containing server configurations") - group.add_argument( - "-p", "--plugin", default="apache", - help="the plugin to be tested") - group.add_argument( - "-n", "--names", action="store_true", help="tests installer's domain " - "name identification") - group.add_argument( - "-a", "--auth", action="store_true", help="tests authenticators") - group.add_argument( - "-i", "--install", action="store_true", help="tests installer's " - "certificate installation (implicitly includes -d)") - group.add_argument( - "-r", "--redirect", action="store_true", help="tests installer's " - "redirecting HTTP to HTTPS (implicitly includes -di)") - group.add_argument( - "--no-simple-http-tls", action="store_true", help="do not use TLS " - "when solving SimpleHTTP challenges") diff --git a/tests/compatibility/plugin_test.py b/tests/compatibility/plugin_test.py index 3fe5e33a3..2d35c8a59 100644 --- a/tests/compatibility/plugin_test.py +++ b/tests/compatibility/plugin_test.py @@ -1,13 +1,78 @@ """Tests Let's Encrypt plugins against different server configurations.""" -import parser -import util +import argparse +import logging +import os + +from tests.compatibility.configurators import common + +DESCRIPTION = """ +Tests Let's Encrypt plugins against different server configuratons. It is +assumed that Docker is already installed. + +""" + + +PLUGINS = {'common' : common.ConfiguratorTester} + + +logger = logging.getLogger(__name__) + + +def get_args(): + """Returns parsed command line arguments.""" + parser = argparse.ArgumentParser(description=DESCRIPTION) + + group = parser.add_argument_group('general') + group.add_argument( + '-c', '--configs', default='configs.tar.gz', + help='a directory or tarball containing server configurations') + group.add_argument( + '-p', '--plugin', default='apache', help='the plugin to be tested') + group.add_argument( + '-a', '--auth', action='store_true', + help='tests the plugin as an authenticator') + group.add_argument( + '-i', '--install', action='store_true', + help='tests the plugin as an installer') + group.add_argument( + '-r', '--redirect', action='store_true', help='tests the plugin\'s ' + 'ability to redirect HTTP to HTTPS (implicitly includes installer ' + 'tests)') + + for plugin in PLUGINS.itervalues(): + plugin.add_parser_arguments(parser) + + args = parser.parse_args() + if args.redirect: + args.install = True + elif not (args.auth or args.install): + args.auth = args.install = args.redirect = True + + return args + + +def setup_logging(): + """Prepares logging for the program""" + fmt = "%(asctime)s:%(levelname)s:%(name)s:%(message)s" + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter(fmt)) + + root_logger = logging.getLogger() + root_logger.setLevel(logging.DEBUG) + root_logger.addHandler(handler) def main(): """Main test script execution.""" - args = parser.parse_args() + setup_logging() + args = get_args() + plugin = PLUGINS[args.plugin](args) + plugin.start_docker('bradmw/apache2.4') + config = os.path.join(plugin.config_dir, 'apache2') + config_file = os.path.join(config, 'apache2.conf') + plugin.execute_in_docker('apachectl -d {0} -f {1} -k restart'.format(config, config_file)) + #plugin.cleanup_from_tests() - print util.setup_tmp_dir(args.tar) -if __name__ == "__main__": - main() +if __name__ == '__main__': + main() diff --git a/tests/compatibility/util.py b/tests/compatibility/util.py index 07b47cb25..bcad974e3 100644 --- a/tests/compatibility/util.py +++ b/tests/compatibility/util.py @@ -1,21 +1,41 @@ """Utility functions for Let's Encrypt plugin tests.""" +import contextlib import os +import shutil +import socket import tarfile import tempfile - -TEMP_DIRECTORY = tempfile.mkdtemp() -# Location of decompressed server root configurations -CONFIGS = os.path.join(TEMP_DIRECTORY, "configs") -SCRIPTS = os.path.join(TEMP_DIRECTORY, "scripts") +from tests.compatibility import errors -def setup_tmp_dir(tar_path): - """Sets up a temporary directory for this run and returns its path.""" - tar = tarfile.open(tar_path, "r:gz") - tar.extractall(os.path.join(tmp_dir, SERVER_ROOTS)) +# Paths used in the program relative to the temp directory +CONFIG_DIR = "configs" +LE_CONFIG = os.path.join("letsencrypt", "config") +LE_LOGS = os.path.join("letsencrypt", "logs") - os.makedirs(os.path.join(tmp_dir, "mnt")) - os.makedirs(os.path.join(tmp_dir, "scripts")) - return tmp_dir +def setup_temp_dir(configs): + """Sets up a temporary directory and extracts server configs""" + temp_dir = tempfile.mkdtemp() + config_dir = os.path.join(temp_dir, CONFIG_DIR) + + if os.path.isdir(configs): + shutil.copytree(configs, config_dir, symlinks=True) + elif tarfile.is_tarfile(configs): + with tarfile.open(configs, 'r') as tar: + tar.extractall(config_dir) + else: + raise errors.Error('Unknown configurations file type') + + return temp_dir + + +def get_two_free_ports(): + """Returns two free ports to use for the tests""" + with contextlib.closing(socket.socket()) as sock1: + with contextlib.closing(socket.socket()) as sock2: + sock1.bind(('', 0)) + sock2.bind(('', 0)) + + return sock1.getsockname()[1], sock2.getsockname()[1] diff --git a/tests/setup.py b/tests/setup.py new file mode 100644 index 000000000..af4da9507 --- /dev/null +++ b/tests/setup.py @@ -0,0 +1,23 @@ +from setuptools import setup +from setuptools import find_packages + + +install_requires = [ + 'letsencrypt', + 'letsencrypt-apache', + 'letsencrypt-nginx', + 'docker-py', + 'mock<1.1.0', # py26 + 'zope.interface', +] + +setup( + name='compatibility-test', + packages=find_packages(), + install_requires=install_requires, + entry_points={ + 'console_scripts': [ + 'compatibility-test = compatibility.plugin_test:main', + ], + }, +)