From e0add1cde12c7937008c57010e614abd134e8038 Mon Sep 17 00:00:00 2001 From: Guillaume Hervier <guillaume.hervier@nexedi.com> Date: Fri, 19 Oct 2018 16:49:14 +0200 Subject: [PATCH] software/slaprunner: Bootstrap tests --- .../testsuite/slaprunner/README.md | 7 + .../testsuite/slaprunner/buildout.hash.cfg | 19 ++ .../testsuite/slaprunner/instance.cfg.in | 44 +++ .../testsuite/slaprunner/software.cfg | 73 ++++ software/slaprunner/test/README.md | 1 + software/slaprunner/test/setup.py | 53 +++ software/slaprunner/test/test.py | 95 ++++++ software/slaprunner/test/utils.py | 321 ++++++++++++++++++ 8 files changed, 613 insertions(+) create mode 100644 software/erp5testnode/testsuite/slaprunner/README.md create mode 100644 software/erp5testnode/testsuite/slaprunner/buildout.hash.cfg create mode 100644 software/erp5testnode/testsuite/slaprunner/instance.cfg.in create mode 100644 software/erp5testnode/testsuite/slaprunner/software.cfg create mode 100644 software/slaprunner/test/README.md create mode 100644 software/slaprunner/test/setup.py create mode 100644 software/slaprunner/test/test.py create mode 100644 software/slaprunner/test/utils.py diff --git a/software/erp5testnode/testsuite/slaprunner/README.md b/software/erp5testnode/testsuite/slaprunner/README.md new file mode 100644 index 000000000..1682f60ea --- /dev/null +++ b/software/erp5testnode/testsuite/slaprunner/README.md @@ -0,0 +1,7 @@ +# slaprunner test + +This software release is simply to run the test suite from `../slaprunner/test/setup.py` + +Nexedi staff can see the results of this test from the test suite +`SLAPOS-SLAPRUNNER-TEST` in test result module. + diff --git a/software/erp5testnode/testsuite/slaprunner/buildout.hash.cfg b/software/erp5testnode/testsuite/slaprunner/buildout.hash.cfg new file mode 100644 index 000000000..c1700c076 --- /dev/null +++ b/software/erp5testnode/testsuite/slaprunner/buildout.hash.cfg @@ -0,0 +1,19 @@ +# THIS IS NOT A BUILDOUT FILE, despite purposedly using a compatible syntax. +# The only allowed lines here are (regexes): +# - "^#" comments, copied verbatim +# - "^[" section beginings, copied verbatim +# - lines containing an "=" sign which must fit in the following categorie. +# - "^\s*filename\s*=\s*path\s*$" where "path" is relative to this file +# Copied verbatim. +# - "^\s*hashtype\s*=.*" where "hashtype" is one of the values supported +# by the re-generation script. +# Re-generated. +# - other lines are copied verbatim +# Substitution (${...:...}), extension ([buildout] extends = ...) and +# section inheritance (< = ...) are NOT supported (but you should really +# not need these here). + +[template] +filename = instance.cfg.in +md5sum = 6fa1420739f58e722631564b08727060 + diff --git a/software/erp5testnode/testsuite/slaprunner/instance.cfg.in b/software/erp5testnode/testsuite/slaprunner/instance.cfg.in new file mode 100644 index 000000000..4a80fe677 --- /dev/null +++ b/software/erp5testnode/testsuite/slaprunner/instance.cfg.in @@ -0,0 +1,44 @@ +[buildout] +parts = + slapos-test-runner + +eggs-directory = ${buildout:eggs-directory} +develop-eggs-directory = ${buildout:develop-eggs-directory} +offline = true + +[slap-configuration] +recipe = slapos.cookbook:slapconfiguration +computer = $${slap-connection:computer-id} +partition = $${slap-connection:partition-id} +url = $${slap-connection:server-url} +key = $${slap-connection:key-file} +cert = $${slap-connection:cert-file} + +[download-source] +recipe = slapos.recipe.build:gitclone +git-executable = ${git:location}/bin/git + +[slapos] +<= download-source +repository = ${slapos-repository:location} + +[create-directory] +recipe = slapos.cookbook:mkdirectory +bin = $${buildout:directory}/bin +working-dir = $${buildout:directory}/tmp/ + +[slapos-test-runner] +recipe = slapos.cookbook:wrapper +wrapper-path = $${create-directory:bin}/runTestSuite +command-line = + ${buildout:bin-directory}/runTestSuite + --python_interpreter=${buildout:bin-directory}/${eggs:interpreter} + --source_code_path_list=$${slapos:location}/software/slaprunner/test + + +# XXX slapos.cookbook:wrapper does not allow extending env, so we add some default $PATH entries ( not sure they are needed ) +environment = + PATH=${buildout:bin-directory}:/usr/bin/:/bin/ + LOCAL_IPV4=$${slap-configuration:ipv4-random} + GLOBAL_IPV6=$${slap-configuration:ipv6-random} + SLAPOS_TEST_WORKING_DIR=$${create-directory:working-dir} diff --git a/software/erp5testnode/testsuite/slaprunner/software.cfg b/software/erp5testnode/testsuite/slaprunner/software.cfg new file mode 100644 index 000000000..9a89666c8 --- /dev/null +++ b/software/erp5testnode/testsuite/slaprunner/software.cfg @@ -0,0 +1,73 @@ +[buildout] + +extends = + ../../../../component/git/buildout.cfg + ../../../../stack/slapos.cfg + ./buildout.hash.cfg + +parts = + slapos-cookbook + eggs + template + +[setup-develop-egg] +recipe = zc.recipe.egg:develop + +[slapos.test.slaprunner-setup] +<= setup-develop-egg +egg = slapos.test.slaprunner +setup = ${slapos-repository:location}/software/slaprunner/test/ + +[erp5.util-setup] +<= setup-develop-egg +egg = erp5.util[testnode] +setup = ${erp5.util-repository:location} + + +[eggs] +recipe = zc.recipe.egg +eggs = + ${slapos.test.slaprunner-setup:egg} + ${erp5.util-setup:egg} + slapos.core +entry-points = + runTestSuite=erp5.util.testsuite:runTestSuite +scripts = + runTestSuite + slapos +interpreter= + python_for_test + +[git-clone-repository] +recipe = slapos.recipe.build:gitclone +git-executable = ${git:location}/bin/git +forbid-download-cache = true +branch = master + +[slapos-repository] +<= git-clone-repository +repository = https://lab.nexedi.com/nexedi/slapos.git + +# XXX we need an unreleased ( 0.4.51 ) version of erp5.util runTestSuite +# later we can stop fetching it from git and just use egg +[erp5.util-repository] +<= git-clone-repository +repository = https://lab.nexedi.com/nexedi/erp5.git +revision = 69013fa0fb67501089c776ab5e75d7bbf2e0e3bc + + +[template] +recipe = slapos.recipe.template +url = ${:_profile_base_location_}/${:filename} +output = ${buildout:directory}/template.cfg +mode = 640 + +[versions] +# clear the version of tested eggs, to make sure we installed the developped ones +slapos.test.slaprunner = +erp5.util = +#erp5.util = 0.4.51 + +pyasn1 = 0.4.2 +slapos.recipe.template = 4.3 +pysftp = 0.2.9 diff --git a/software/slaprunner/test/README.md b/software/slaprunner/test/README.md new file mode 100644 index 000000000..16dd7965a --- /dev/null +++ b/software/slaprunner/test/README.md @@ -0,0 +1 @@ +Tests for slaprunner Software Release diff --git a/software/slaprunner/test/setup.py b/software/slaprunner/test/setup.py new file mode 100644 index 000000000..5fbfdb84f --- /dev/null +++ b/software/slaprunner/test/setup.py @@ -0,0 +1,53 @@ +############################################################################## +# +# Copyright (c) 2018 Nexedi SA and Contributors. All Rights Reserved. +# +# WARNING: This program as such is intended to be used by professional +# programmers who take the whole responsibility of assessing all potential +# consequences resulting from its eventual inadequacies and bugs +# End users who are looking for a ready-to-use solution with commercial +# guarantees and support are strongly adviced to contract a Free Software +# Service Company +# +# This program is Free Software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 3 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +# +############################################################################## +from setuptools import setup, find_packages +import glob +import os + +version = '0.0.1.dev0' +name = 'slapos.test.slaprunner' +long_description = open("README.md").read() + +setup(name=name, + version=version, + description="Test for SlapOS' slaprunner", + long_description=long_description, + long_description_content_type='text/markdown', + maintainer="Nexedi", + maintainer_email="info@nexedi.com", + url="https://lab.nexedi.com/nexedi/slapos", + packages=find_packages(), + install_requires=[ + 'slapos.core', + 'slapos.libnetworkcache', + 'erp5.util', + 'supervisor', + 'psutil', + ], + zip_safe=True, + test_suite='test', + ) diff --git a/software/slaprunner/test/test.py b/software/slaprunner/test/test.py new file mode 100644 index 000000000..23d1f728b --- /dev/null +++ b/software/slaprunner/test/test.py @@ -0,0 +1,95 @@ +############################################################################## +# +# Copyright (c) 2018 Nexedi SA and Contributors. All Rights Reserved. +# +# WARNING: This program as such is intended to be used by professional +# programmers who take the whole responsibility of assessing all potential +# consequences resulting from its eventual inadequacies and bugs +# End users who are looking for a ready-to-use solution with commercial +# guarantees and support are strongly adviced to contract a Free Software +# Service Company +# +# This program is Free Software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 3 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +# +############################################################################## + +import os +import shutil +import urlparse +import tempfile +import requests +import socket +import StringIO +import subprocess +import json + +import psutil + +import utils + +# for development: debugging logs and install Ctrl+C handler +if os.environ.get('DEBUG'): + import logging + logging.basicConfig(level=logging.DEBUG) + import unittest + unittest.installHandler() + + +class InstanceTestCase(utils.SlapOSInstanceTestCase): + @classmethod + def getSoftwareURLList(cls): + return (os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'software.cfg')), ) + +class ServicesTestCase(InstanceTestCase): + @staticmethod + def generateHashFromFiles(file_list): + import hashlib + hasher = hashlib.md5() + for path in file_list: + with open(path, 'r') as afile: + buf = afile.read() + hasher.update("%s\n" % len(buf)) + hasher.update(buf) + hash = hasher.hexdigest() + return hash + + def test_hashes(self): + hash_files = [ + 'software_release/buildout.cfg', + ] + expected_process_names = [ + 'slaprunner-supervisord-{hash}-on-watch', + 'runner-sshkeys-authority-{hash}-on-watch', + 'runner-sshd-{hash}-on-watch', + 'slaprunner-httpd-{hash}-on-watch', + 'gunicorn-{hash}-on-watch', + 'nginx-frontend-{hash}-on-watch', + 'certificate_authority-{hash}-on-watch', + 'shellinaboxd-{hash}-on-watch', + 'supervisord-{hash}-on-watch', + ] + + supervisor = self.getSupervisorRPCServer().supervisor + process_names = [process['name'] + for process in supervisor.getAllProcessInfo()] + + hash_files = [os.path.join(self.computer_partition_root_path, path) + for path in hash_files] + + for name in expected_process_names: + h = ServicesTestCase.generateHashFromFiles(hash_files) + expected_process_name = name.format(hash=h) + + self.assertIn(expected_process_name, process_names) diff --git a/software/slaprunner/test/utils.py b/software/slaprunner/test/utils.py new file mode 100644 index 000000000..6b3f23045 --- /dev/null +++ b/software/slaprunner/test/utils.py @@ -0,0 +1,321 @@ +############################################################################## +# +# Copyright (c) 2018 Nexedi SA and Contributors. All Rights Reserved. +# +# WARNING: This program as such is intended to be used by professional +# programmers who take the whole responsibility of assessing all potential +# consequences resulting from its eventual inadequacies and bugs +# End users who are looking for a ready-to-use solution with commercial +# guarantees and support are strongly adviced to contract a Free Software +# Service Company +# +# This program is Free Software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 3 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +# +############################################################################## + +import unittest +import os +import socket +from contextlib import closing +import logging +import StringIO +import xmlrpclib + +import supervisor.xmlrpc +from erp5.util.testnode.SlapOSControler import SlapOSControler +from erp5.util.testnode.ProcessManager import ProcessManager + + +# Utility functions +def findFreeTCPPort(ip=''): + """Find a free TCP port to listen to. + """ + family = socket.AF_INET6 if ':' in ip else socket.AF_INET + with closing(socket.socket(family, socket.SOCK_STREAM)) as s: + s.bind((ip, 0)) + return s.getsockname()[1] + + +# TODO: +# - allow requesting multiple instances ? + +class SlapOSInstanceTestCase(unittest.TestCase): + """Install one slapos instance. + + This test case install software(s) and request one instance during `setUpClass` + and destroy the instance during `tearDownClass`. + + Software Release URL, Instance Software Type and Instance Parameters can be defined + on the class. + + All tests from the test class will run with the same instance. + + The following class attributes are available: + + * `computer_partition`: the computer partition instance, implementing + `slapos.slap.interface.slap.IComputerPartition`. + + * `computer_partition_root_path`: the path of the instance root directory. + + """ + + # Methods to be defined by subclasses. + @classmethod + def getSoftwareURLList(cls): + """Return URL of software releases to install. + + To be defined by subclasses. + """ + raise NotImplementedError() + + @classmethod + def getInstanceParameterDict(cls): + """Return instance parameters + + To be defined by subclasses if they need to request instance with specific + parameters. + """ + return {} + + @classmethod + def getInstanceSoftwareType(cls): + """Return software type for instance, default "default" + + To be defined by subclasses if they need to request instance with specific + software type. + """ + return "default" + + # Utility methods. + def getSupervisorRPCServer(self): + """Returns a XML-RPC connection to the supervisor used by slapos node + + Refer to http://supervisord.org/api.html for details of available methods. + """ + # xmlrpc over unix socket https://stackoverflow.com/a/11746051/7294664 + return xmlrpclib.ServerProxy( + 'http://slapos-supervisor', + transport=supervisor.xmlrpc.SupervisorTransport( + None, + None, + # XXX hardcoded socket path + serverurl="unix://{working_directory}/inst/supervisord.socket".format( + **self.config))) + + # Unittest methods + @classmethod + def setUpClass(cls): + """Setup the class, build software and request an instance. + + If you have to override this method, do not forget to call this method on + parent class. + """ + try: + cls.setUpWorkingDirectory() + cls.setUpConfig() + cls.setUpSlapOSController() + + cls.runSoftwareRelease() + # XXX instead of "runSoftwareRelease", it would be better to be closer to slapos usage: + # cls.supplySoftwares() + # cls.installSoftwares() + + cls.runComputerPartition() + # XXX instead of "runComputerPartition", it would be better to be closer to slapos usage: + # cls.requestInstances() + # cls.createInstances() + # cls.requestInstances() + + except Exception: + cls.stopSlapOSProcesses() + raise + + @classmethod + def tearDownClass(cls): + """Tear down class, stop the processes and destroy instance. + """ + cls.stopSlapOSProcesses() + + # Implementation + @classmethod + def stopSlapOSProcesses(cls): + if hasattr(cls, '_process_manager'): + cls._process_manager.killPreviousRun() + + @classmethod + def setUpWorkingDirectory(cls): + """Initialise the directories""" + cls.working_directory = os.environ.get( + 'SLAPOS_TEST_WORKING_DIR', + os.path.join(os.path.dirname(__file__), '.slapos')) + # To prevent error: Cannot open an HTTP server: socket.error reported + # AF_UNIX path too long This `working_directory` should not be too deep. + # Socket path is 108 char max on linux + # https://github.com/torvalds/linux/blob/3848ec5/net/unix/af_unix.c#L234-L238 + # Supervisord socket name contains the pid number, which is why we add + # .xxxxxxx in this check. + if len(cls.working_directory + '/inst/supervisord.socket.xxxxxxx') > 108: + raise RuntimeError('working directory ( {} ) is too deep, try setting ' + 'SLAPOS_TEST_WORKING_DIR'.format(cls.working_directory)) + + if not os.path.exists(cls.working_directory): + os.mkdir(cls.working_directory) + + @classmethod + def setUpConfig(cls): + """Create slapos configuration""" + cls.config = { + "working_directory": cls.working_directory, + "slapos_directory": cls.working_directory, + "log_directory": cls.working_directory, + "computer_id": 'slapos.test', # XXX + 'proxy_database': os.path.join(cls.working_directory, 'proxy.db'), + 'partition_reference': cls.__name__, + # "proper" slapos command must be in $PATH + 'slapos_binary': 'slapos', + } + # Some tests are expecting that local IP is not set to 127.0.0.1 + ipv4_address = os.environ.get('LOCAL_IPV4', '127.0.1.1') + ipv6_address = os.environ['GLOBAL_IPV6'] + + cls.config['proxy_host'] = cls.config['ipv4_address'] = ipv4_address + cls.config['ipv6_address'] = ipv6_address + cls.config['proxy_port'] = findFreeTCPPort(ipv4_address) + cls.config['master_url'] = 'http://{proxy_host}:{proxy_port}'.format( + **cls.config) + + @classmethod + def setUpSlapOSController(cls): + """Create the a "slapos controller" and supply softwares from `getSoftwareURLList`. + + This is equivalent to: + + slapos proxy start + for sr in getSoftwareURLList; do + slapos supply $SR $COMP + done + """ + cls._process_manager = ProcessManager() + + # XXX this code is copied from testnode code + cls.slapos_controler = SlapOSControler( + cls.working_directory, + cls.config + ) + + slapproxy_log = os.path.join(cls.config['log_directory'], 'slapproxy.log') + logger = logging.getLogger(__name__) + logger.debug('Configured slapproxy log to %r', slapproxy_log) + + cls.software_url_list = cls.getSoftwareURLList() + cls.slapos_controler.initializeSlapOSControler( + slapproxy_log=slapproxy_log, + process_manager=cls._process_manager, + reset_software=False, + software_path_list=cls.software_url_list) + + # XXX we should check *earlier* if that pidfile exist and if supervisord + # process still running, because if developer started supervisord (or bugs?) + # then another supervisord will start and starting services a second time + # will fail. + cls._process_manager.supervisord_pid_file = os.path.join( + cls.slapos_controler.instance_root, 'var', 'run', 'supervisord.pid') + + @classmethod + def runSoftwareRelease(cls): + """Run all the software releases that were supplied before. + + This is the equivalent of `slapos node software`. + + The tests will be marked file if software building fail. + """ + logger = logging.getLogger() + logger.level = logging.DEBUG + stream = StringIO.StringIO() + stream_handler = logging.StreamHandler(stream) + logger.addHandler(stream_handler) + + try: + cls.software_status_dict = cls.slapos_controler.runSoftwareRelease( + cls.config, environment=os.environ) + stream.seek(0) + stream.flush() + message = ''.join(stream.readlines()[-100:]) + assert cls.software_status_dict['status_code'] == 0, message + finally: + logger.removeHandler(stream_handler) + del stream + + + @classmethod + def runComputerPartition(cls): + """Instanciate the software. + + This is the equivalent of doing: + + slapos request --type=getInstanceSoftwareType --parameters=getInstanceParameterDict + slapos node instance + + and return the slapos request instance parameters. + + This can be called by tests to simulate re-request with different parameters. + """ + logger = logging.getLogger() + logger.level = logging.DEBUG + stream = StringIO.StringIO() + stream_handler = logging.StreamHandler(stream) + logger.addHandler(stream_handler) + + if cls.getInstanceSoftwareType() != 'default': + raise NotImplementedError + + instance_parameter_dict = cls.getInstanceParameterDict() + try: + cls.instance_status_dict = cls.slapos_controler.runComputerPartition( + cls.config, + cluster_configuration=instance_parameter_dict, + environment=os.environ) + stream.seek(0) + stream.flush() + message = ''.join(stream.readlines()[-100:]) + assert cls.instance_status_dict['status_code'] == 0, message + finally: + logger.removeHandler(stream_handler) + del stream + + # FIXME: similar to test node, only one (root) partition is really + # supported for now. + computer_partition_list = [] + for i in range(len(cls.software_url_list)): + computer_partition_list.append( + cls.slapos_controler.slap.registerOpenOrder().request( + cls.software_url_list[i], + # This is how testnode's SlapOSControler name created partitions + partition_reference='testing partition {i}'.format( + i=i, **cls.config), + partition_parameter_kw=instance_parameter_dict)) + + # expose some class attributes so that tests can use them: + # the ComputerPartition instances, to getInstanceParameterDict + cls.computer_partition = computer_partition_list[0] + + # the path of the instance on the filesystem, for low level inspection + cls.computer_partition_root_path = os.path.join( + cls.config['working_directory'], + 'inst', + cls.computer_partition.getId()) + + + -- 2.30.9