source: mod_gnutls/test/runtest.py @ 7eb4233

asyncio
Last change on this file since 7eb4233 was 7eb4233, checked in by Fiona Klute <fiona.klute@…>, 8 months ago

Use asyncio to manage test services

This makes it a little easier to handle the services in parallel.

  • Property mode set to 100755
File size: 9.5 KB
Line 
1#!/usr/bin/python3
2# PYTHON_ARGCOMPLETE_OK
3
4# Copyright 2019-2020 Fiona Klute
5#
6# Licensed under the Apache License, Version 2.0 (the "License");
7# you may not use this file except in compliance with the License.
8# You may obtain a copy of the License at
9#
10#     http://www.apache.org/licenses/LICENSE-2.0
11#
12# Unless required by applicable law or agreed to in writing, software
13# distributed under the License is distributed on an "AS IS" BASIS,
14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15# See the License for the specific language governing permissions and
16# limitations under the License.
17
18import asyncio
19import contextlib
20import os
21import os.path
22import subprocess
23import sys
24import tempfile
25import yaml
26from unittest import SkipTest
27
28import mgstest.hooks
29import mgstest.valgrind
30from mgstest import lockfile, TestExpectationFailed
31from mgstest.services import ApacheService, TestService
32from mgstest.tests import run_test_conf
33
34
35def find_testdir(number, dir):
36    """Find the configuration directory for a test based on its
37    number. The given directory must contain exactly one directory
38    with a name matching "NUMBER_*", otherwise a LookupError is
39    raised.
40
41    """
42    with os.scandir(dir) as it:
43        found = None
44        for entry in it:
45            if entry.is_dir():
46                num = int(entry.name.split('_', maxsplit=1)[0])
47                if number == num:
48                    if found:
49                        # duplicate numbers are an error
50                        raise LookupError('Multiple directories found for '
51                                          f'test number {number}: '
52                                          f'{found.name} and {entry.name}')
53                    else:
54                        found = entry
55        if found is None:
56            raise LookupError('No test directory found for test number '
57                              f'{number}!')
58        else:
59            return (found.path, found.name)
60
61
62def temp_logfile():
63    return tempfile.SpooledTemporaryFile(max_size=4096, mode='w+',
64                                         prefix='mod_gnutls', suffix=".log")
65
66
67def check_ocsp_responder():
68    # Check if OCSP responder works
69    issuer_cert = 'authority/x509.pem'
70    check_cert = 'authority/server/x509.pem'
71    command = ['ocsptool', '--ask', '--nonce',
72               '--load-issuer', issuer_cert,
73               '--load-cert', check_cert]
74    return subprocess.run(command).returncode == 0
75
76
77def check_msva():
78    # Check if MSVA is up
79    cert_file = 'authority/client/x509.pem'
80    uid_file = 'authority/client/uid'
81    with open(uid_file, 'r') as file:
82        uid = file.read().strip()
83        command = ['msva-query-agent', 'https', uid, 'x509pem', 'client']
84        with open(cert_file, 'r') as cert:
85            return subprocess.run(command, stdin=cert).returncode == 0
86
87
88async def main(args):
89    # The Automake environment always provides srcdir, the default is
90    # for manual use.
91    srcdir = os.path.realpath(os.environ.get('srcdir', '.'))
92    # ensure environment srcdir is absolute
93    os.environ['srcdir'] = srcdir
94
95    # Find the configuration directory for the test in
96    # ${srcdir}/tests/, based on the test number.
97    testdir, testname = find_testdir(args.test_number,
98                                     os.path.join(srcdir, 'tests'))
99    print(f'Found test {testname}, test dir is {testdir}')
100    os.environ['TEST_NAME'] = testname
101
102    # Load test config
103    try:
104        with open(os.path.join(testdir, 'test.yaml'), 'r') as conf_file:
105            test_conf = yaml.load(conf_file, Loader=yaml.Loader)
106    except FileNotFoundError:
107        test_conf = None
108
109    # Load test case hooks (if any)
110    plugin_path = os.path.join(testdir, 'hooks.py')
111    plugin = mgstest.hooks.load_hooks_plugin(plugin_path)
112
113    # PID file name varies depending on whether we're using
114    # namespaces.
115    #
116    # TODO: Check if having the different names is really necessary.
117    pidaffix = ''
118    if 'USE_TEST_NAMESPACE' in os.environ:
119        pidaffix = f'-{testname}'
120
121    valgrind_log = None
122    if args.valgrind:
123        valgrind_log = os.path.join('logs', f'valgrind-{testname}.log')
124
125    # Define the available services
126    apache = ApacheService(config=os.path.join(testdir, 'apache.conf'),
127                           pidfile=f'apache2{pidaffix}.pid',
128                           valgrind_log=valgrind_log,
129                           valgrind_suppress=args.valgrind_suppressions)
130    backend = ApacheService(config=os.path.join(testdir, 'backend.conf'),
131                            pidfile=f'backend{pidaffix}.pid')
132    ocsp = ApacheService(config=os.path.join(testdir, 'ocsp.conf'),
133                         pidfile=f'ocsp{pidaffix}.pid',
134                         check=check_ocsp_responder)
135    msva = TestService(start=['monkeysphere-validation-agent'],
136                       env={'GNUPGHOME': 'msva.gnupghome',
137                            'MSVA_KEYSERVER_POLICY': 'never'},
138                       condition=lambda: 'USE_MSVA' in os.environ,
139                       check=check_msva)
140
141    # background services: must be ready before the main apache
142    # instance is started
143    bg_services = [backend, ocsp, msva]
144
145    # If VERBOSE is enabled, log the HTTPD build configuration
146    if 'VERBOSE' in os.environ:
147        apache2 = os.environ.get('APACHE2', 'apache2')
148        subprocess.run([apache2, '-f', f'{srcdir}/base_apache.conf', '-V'],
149                       check=True)
150
151    # This hook may modify the environment as needed for the test.
152    cleanup_callback = None
153    try:
154        if plugin.prepare_env:
155            cleanup_callback = plugin.prepare_env()
156    except SkipTest as skip:
157        print(f'Skipping: {skip!s}')
158        sys.exit(77)
159
160    if 'USE_MSVA' in os.environ:
161        os.environ['MONKEYSPHERE_VALIDATION_AGENT_SOCKET'] = \
162            f'http://127.0.0.1:{os.environ["MSVA_PORT"]}'
163
164    async with contextlib.AsyncExitStack() as service_stack:
165        if cleanup_callback:
166            service_stack.callback(cleanup_callback)
167        service_stack.enter_context(
168            lockfile('test.lock', nolock='MGS_NETNS_ACTIVE' in os.environ))
169
170        # TEST_SERVICE_MAX_WAIT is in milliseconds
171        wait_timeout = \
172            int(os.environ.get('TEST_SERVICE_MAX_WAIT', 10000)) / 1000
173        await asyncio.wait(
174            {asyncio.create_task(
175                service_stack.enter_async_context(
176                    s.run(ready_timeout=wait_timeout)))
177             for s in bg_services})
178
179        # special case: expected to fail in a few cases
180        await service_stack.enter_async_context(apache.run())
181        failed = await apache.wait_ready()
182        if os.path.exists(os.path.join(testdir, 'fail.server')):
183            if failed:
184                print('Apache server failed to start as expected',
185                      file=sys.stderr)
186            else:
187                raise TestExpectationFailed(
188                    'Server start did not fail as expected!')
189
190        # Set TEST_TARGET for the request. Might be replaced with a
191        # parameter later.
192        if 'TARGET_IP' in os.environ:
193            os.environ['TEST_TARGET'] = os.environ['TARGET_IP']
194        else:
195            os.environ['TEST_TARGET'] = os.environ['TEST_HOST']
196
197        # Run the test connections
198        if plugin.run_connection:
199            plugin.run_connection(testname,
200                                  conn_log=args.log_connection,
201                                  response_log=args.log_responses)
202        else:
203            run_test_conf(test_conf,
204                          float(os.environ.get('TEST_QUERY_TIMEOUT', 5.0)),
205                          conn_log=args.log_connection,
206                          response_log=args.log_responses)
207
208    # run extra checks the test's hooks.py might define
209    if plugin.post_check:
210        args.log_connection.seek(0)
211        args.log_responses.seek(0)
212        plugin.post_check(conn_log=args.log_connection,
213                          response_log=args.log_responses)
214
215    if valgrind_log:
216        with open(valgrind_log) as log:
217            errors = mgstest.valgrind.error_summary(log)
218            print(f'Valgrind summary: {errors[0]} errors, '
219                  f'{errors[1]} suppressed')
220            if errors[0] > 0:
221                sys.exit(ord('V'))
222
223
224if __name__ == "__main__":
225    import argparse
226    parser = argparse.ArgumentParser(
227        description='Run a mod_gnutls server test')
228    parser.add_argument('--test-number', type=int,
229                        required=True, help='load YAML test configuration')
230    parser.add_argument('--log-connection', type=argparse.FileType('w+'),
231                        default=temp_logfile(),
232                        help='write connection log to this file')
233    parser.add_argument('--log-responses', type=argparse.FileType('w+'),
234                        default=temp_logfile(),
235                        help='write HTTP responses to this file')
236    parser.add_argument('--valgrind', action='store_true',
237                        help='run primary Apache instance with Valgrind')
238    parser.add_argument('--valgrind-suppressions', action='append',
239                        default=[],
240                        help='use Valgrind suppressions file')
241
242    # enable bash completion if argcomplete is available
243    try:
244        import argcomplete
245        argcomplete.autocomplete(parser)
246    except ImportError:
247        pass
248
249    args = parser.parse_args()
250
251    with contextlib.ExitStack() as stack:
252        stack.enter_context(contextlib.closing(args.log_connection))
253        stack.enter_context(contextlib.closing(args.log_responses))
254        asyncio.run(main(args))
Note: See TracBrowser for help on using the repository browser.