source: mod_gnutls/test/runtest.py @ 65e66c9

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

Turn "service ready" check functions into coroutines

This way checks that call subprocesses don't block other tasks.

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