source: mod_gnutls/test/runtest.py @ b6ce8ad

main mod_gnutls/0.12.0
Last change on this file since b6ce8ad was 0fe2130, checked in by Fiona Klute <fiona.klute@…>, 2 years ago

Simplify lockfile handling

It's much easier to just not enter the lockfile context if it's not
needed than to have a special case in the context manager.

  • Property mode set to 100755
File size: 9.3 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 itertools
21import os
22import subprocess
23import sys
24import tempfile
25import yaml
26from pathlib import Path
27from unittest import SkipTest
28
29import mgstest.hooks
30import mgstest.valgrind
31from mgstest import lockfile, TestExpectationFailed
32from mgstest.services import ApacheService, TestService
33from mgstest.tests import run_test_conf
34
35
36def find_testdir(number, dir):
37    """Find the configuration directory for a test based on its
38    number. The given directory must contain exactly one directory
39    with a name matching "NUMBER_*", otherwise a LookupError is
40    raised.
41
42    """
43    found = None
44    for entry in dir.iterdir():
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    return found, found.name
59
60
61def temp_logfile():
62    return tempfile.SpooledTemporaryFile(max_size=4096, mode='w+',
63                                         prefix='mod_gnutls', suffix=".log")
64
65
66async def check_ocsp_responder():
67    # Check if OCSP responder works
68    issuer_cert = 'authority/x509.pem'
69    check_cert = 'authority/server/x509.pem'
70    proc = await asyncio.create_subprocess_exec(
71        'ocsptool', '--ask', '--nonce',
72        '--load-issuer', issuer_cert, '--load-cert', check_cert)
73    return (await proc.wait()) == 0
74
75
76async def check_msva():
77    # Check if MSVA is up
78    cert_file = 'authority/client/x509.pem'
79    uid_file = 'authority/client/uid'
80    with open(uid_file, 'r') as file:
81        uid = file.read().strip()
82    with open(cert_file, 'r') as cert:
83        proc = await asyncio.create_subprocess_exec(
84            'msva-query-agent', 'https', uid, 'x509pem', 'client',
85            stdin=cert)
86        return (await proc.wait()) == 0
87
88
89async def main(args):
90    # The Automake environment always provides srcdir, the default is
91    # for manual use.
92    srcdir = Path(os.environ.get('srcdir', '.')).resolve()
93    # ensure environment srcdir is absolute
94    os.environ['srcdir'] = str(srcdir)
95
96    # Find the configuration directory for the test in
97    # ${srcdir}/tests/, based on the test number.
98    testdir, testname = find_testdir(args.test_number, 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(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 = mgstest.hooks.load_hooks_plugin(testdir / 'hooks.py')
111
112    # PID file name varies depending on whether we're using
113    # namespaces.
114    #
115    # TODO: Check if having the different names is really necessary.
116    pidaffix = ''
117    if 'USE_TEST_NAMESPACE' in os.environ:
118        pidaffix = f'-{testname}'
119
120    valgrind_log = None
121    if args.valgrind:
122        valgrind_log = Path('logs', f'valgrind-{testname}.log')
123
124    # Define the available services
125    apache = ApacheService(config=testdir / 'apache.conf',
126                           pidfile=f'apache2{pidaffix}.pid',
127                           valgrind_log=valgrind_log,
128                           valgrind_suppress=args.valgrind_suppressions)
129    backend = ApacheService(config=testdir / 'backend.conf',
130                            pidfile=f'backend{pidaffix}.pid')
131    ocsp = ApacheService(config=testdir / 'ocsp.conf',
132                         pidfile=f'ocsp{pidaffix}.pid',
133                         check=check_ocsp_responder)
134    msva = TestService(start=['monkeysphere-validation-agent'],
135                       env={'GNUPGHOME': 'msva.gnupghome',
136                            'MSVA_KEYSERVER_POLICY': 'never'},
137                       condition=lambda: 'USE_MSVA' in os.environ,
138                       check=check_msva)
139
140    # background services: must be ready before the main apache
141    # instance is started
142    bg_services = [backend, ocsp, msva]
143
144    # If VERBOSE is enabled, log the HTTPD build configuration
145    if 'VERBOSE' in os.environ:
146        apache2 = os.environ.get('APACHE2', 'apache2')
147        subprocess.run([apache2, '-f', f'{srcdir}/base_apache.conf', '-V'],
148                       check=True)
149
150    # This hook may modify the environment as needed for the test.
151    cleanup_callback = None
152    try:
153        if plugin.prepare_env:
154            cleanup_callback = plugin.prepare_env()
155    except SkipTest as skip:
156        print(f'Skipping: {skip!s}')
157        sys.exit(77)
158
159    if 'USE_MSVA' in os.environ:
160        os.environ['MONKEYSPHERE_VALIDATION_AGENT_SOCKET'] = \
161            f'http://127.0.0.1:{os.environ["MSVA_PORT"]}'
162
163    async with contextlib.AsyncExitStack() as service_stack:
164        if cleanup_callback:
165            service_stack.callback(cleanup_callback)
166        if 'MGS_NETNS_ACTIVE' not in os.environ:
167            service_stack.enter_context(lockfile('test.lock'))
168
169        wait_timeout = float(os.environ.get('TEST_SERVICE_MAX_WAIT', 10))
170        await asyncio.gather(*(
171            asyncio.create_task(
172                service_stack.enter_async_context(
173                    s.run(ready_timeout=wait_timeout)))
174            for s in bg_services))
175
176        # special case: expected to fail in a few cases
177        await service_stack.enter_async_context(apache.run())
178        failed = await apache.wait_ready()
179        if (testdir / 'fail.server').is_file():
180            if failed:
181                print('Apache server failed to start as expected',
182                      file=sys.stderr)
183            else:
184                raise TestExpectationFailed(
185                    'Server start did not fail as expected!')
186
187        # Set TEST_TARGET for the request. Might be replaced with a
188        # parameter later.
189        if 'TARGET_IP' in os.environ:
190            os.environ['TEST_TARGET'] = os.environ['TARGET_IP']
191        else:
192            os.environ['TEST_TARGET'] = os.environ['TEST_HOST']
193
194        # Run the test connections
195        if plugin.run_connection:
196            plugin.run_connection(testname,
197                                  conn_log=args.log_connection,
198                                  response_log=args.log_responses)
199        else:
200            run_test_conf(test_conf,
201                          float(os.environ.get('TEST_QUERY_TIMEOUT', 5.0)),
202                          conn_log=args.log_connection,
203                          response_log=args.log_responses)
204
205        await asyncio.wait(
206            {asyncio.create_task(s.stop())
207             for s in itertools.chain([apache], bg_services)})
208
209    # run extra checks the test's hooks.py might define
210    if plugin.post_check:
211        args.log_connection.seek(0)
212        args.log_responses.seek(0)
213        plugin.post_check(conn_log=args.log_connection,
214                          response_log=args.log_responses)
215
216    if valgrind_log:
217        with open(valgrind_log) as log:
218            errors = mgstest.valgrind.error_summary(log)
219            print(f'Valgrind summary: {errors[0]} errors, '
220                  f'{errors[1]} suppressed')
221            if errors[0] > 0:
222                sys.exit(ord('V'))
223
224
225if __name__ == "__main__":
226    import argparse
227    parser = argparse.ArgumentParser(
228        description='Run a mod_gnutls server test')
229    parser.add_argument('--test-number', type=int,
230                        required=True, help='load YAML test configuration')
231    parser.add_argument('--log-connection', type=argparse.FileType('w+'),
232                        default=temp_logfile(),
233                        help='write connection log to this file')
234    parser.add_argument('--log-responses', type=argparse.FileType('w+'),
235                        default=temp_logfile(),
236                        help='write HTTP responses to this file')
237    parser.add_argument('--valgrind', action='store_true',
238                        help='run primary Apache instance with Valgrind')
239    parser.add_argument('--valgrind-suppressions', action='append',
240                        default=[],
241                        help='use Valgrind suppressions file')
242
243    # enable bash completion if argcomplete is available
244    try:
245        import argcomplete
246        argcomplete.autocomplete(parser)
247    except ImportError:
248        pass
249
250    args = parser.parse_args()
251
252    with contextlib.ExitStack() as stack:
253        stack.enter_context(contextlib.closing(args.log_connection))
254        stack.enter_context(contextlib.closing(args.log_responses))
255        asyncio.run(main(args))
Note: See TracBrowser for help on using the repository browser.