source: mod_gnutls/test/runtest.py @ 5f0e94b

Last change on this file since 5f0e94b was 5f0e94b, checked in by Fiona Klute <fiona.klute@…>, 2 years ago

Use pathlib instead of os.path

  • Property mode set to 100755
File size: 9.4 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        service_stack.enter_context(
167            lockfile('test.lock', nolock='MGS_NETNS_ACTIVE' in os.environ))
168
169        # TEST_SERVICE_MAX_WAIT is in milliseconds
170        wait_timeout = \
171            int(os.environ.get('TEST_SERVICE_MAX_WAIT', 10000)) / 1000
172        await asyncio.wait(
173            {asyncio.create_task(
174                service_stack.enter_async_context(
175                    s.run(ready_timeout=wait_timeout)))
176             for s in bg_services})
177
178        # special case: expected to fail in a few cases
179        await service_stack.enter_async_context(apache.run())
180        failed = await apache.wait_ready()
181        if (testdir / 'fail.server').is_file():
182            if failed:
183                print('Apache server failed to start as expected',
184                      file=sys.stderr)
185            else:
186                raise TestExpectationFailed(
187                    'Server start did not fail as expected!')
188
189        # Set TEST_TARGET for the request. Might be replaced with a
190        # parameter later.
191        if 'TARGET_IP' in os.environ:
192            os.environ['TEST_TARGET'] = os.environ['TARGET_IP']
193        else:
194            os.environ['TEST_TARGET'] = os.environ['TEST_HOST']
195
196        # Run the test connections
197        if plugin.run_connection:
198            plugin.run_connection(testname,
199                                  conn_log=args.log_connection,
200                                  response_log=args.log_responses)
201        else:
202            run_test_conf(test_conf,
203                          float(os.environ.get('TEST_QUERY_TIMEOUT', 5.0)),
204                          conn_log=args.log_connection,
205                          response_log=args.log_responses)
206
207        await asyncio.wait(
208            {asyncio.create_task(s.stop())
209             for s in itertools.chain([apache], bg_services)})
210
211    # run extra checks the test's hooks.py might define
212    if plugin.post_check:
213        args.log_connection.seek(0)
214        args.log_responses.seek(0)
215        plugin.post_check(conn_log=args.log_connection,
216                          response_log=args.log_responses)
217
218    if valgrind_log:
219        with open(valgrind_log) as log:
220            errors = mgstest.valgrind.error_summary(log)
221            print(f'Valgrind summary: {errors[0]} errors, '
222                  f'{errors[1]} suppressed')
223            if errors[0] > 0:
224                sys.exit(ord('V'))
225
226
227if __name__ == "__main__":
228    import argparse
229    parser = argparse.ArgumentParser(
230        description='Run a mod_gnutls server test')
231    parser.add_argument('--test-number', type=int,
232                        required=True, help='load YAML test configuration')
233    parser.add_argument('--log-connection', type=argparse.FileType('w+'),
234                        default=temp_logfile(),
235                        help='write connection log to this file')
236    parser.add_argument('--log-responses', type=argparse.FileType('w+'),
237                        default=temp_logfile(),
238                        help='write HTTP responses to this file')
239    parser.add_argument('--valgrind', action='store_true',
240                        help='run primary Apache instance with Valgrind')
241    parser.add_argument('--valgrind-suppressions', action='append',
242                        default=[],
243                        help='use Valgrind suppressions file')
244
245    # enable bash completion if argcomplete is available
246    try:
247        import argcomplete
248        argcomplete.autocomplete(parser)
249    except ImportError:
250        pass
251
252    args = parser.parse_args()
253
254    with contextlib.ExitStack() as stack:
255        stack.enter_context(contextlib.closing(args.log_connection))
256        stack.enter_context(contextlib.closing(args.log_responses))
257        asyncio.run(main(args))
Note: See TracBrowser for help on using the repository browser.