xref: /unit/test/conftest.py (revision 2062:ede07f84045b)
1import fcntl
2import inspect
3import json
4import os
5import platform
6import re
7import shutil
8import signal
9import stat
10import subprocess
11import sys
12import tempfile
13import time
14from multiprocessing import Process
15
16import pytest
17from unit.check.chroot import check_chroot
18from unit.check.go import check_go
19from unit.check.isolation import check_isolation
20from unit.check.node import check_node
21from unit.check.regex import check_regex
22from unit.check.tls import check_openssl
23from unit.http import TestHTTP
24from unit.log import Log
25from unit.option import option
26from unit.utils import public_dir
27from unit.utils import waitforfiles
28
29
30def pytest_addoption(parser):
31    parser.addoption(
32        "--detailed",
33        default=False,
34        action="store_true",
35        help="Detailed output for tests",
36    )
37    parser.addoption(
38        "--print-log",
39        default=False,
40        action="store_true",
41        help="Print unit.log to stdout in case of errors",
42    )
43    parser.addoption(
44        "--save-log",
45        default=False,
46        action="store_true",
47        help="Save unit.log after the test execution",
48    )
49    parser.addoption(
50        "--unsafe",
51        default=False,
52        action="store_true",
53        help="Run unsafe tests",
54    )
55    parser.addoption(
56        "--user",
57        type=str,
58        help="Default user for non-privileged processes of unitd",
59    )
60    parser.addoption(
61        "--fds-threshold",
62        type=int,
63        default=0,
64        help="File descriptors threshold",
65    )
66    parser.addoption(
67        "--restart",
68        default=False,
69        action="store_true",
70        help="Force Unit to restart after every test",
71    )
72
73
74unit_instance = {}
75_processes = []
76_fds_info = {
77    'main': {'fds': 0, 'skip': False},
78    'router': {'name': 'unit: router', 'pid': -1, 'fds': 0, 'skip': False},
79    'controller': {
80        'name': 'unit: controller',
81        'pid': -1,
82        'fds': 0,
83        'skip': False,
84    },
85}
86http = TestHTTP()
87
88
89def pytest_configure(config):
90    option.config = config.option
91
92    option.detailed = config.option.detailed
93    option.fds_threshold = config.option.fds_threshold
94    option.print_log = config.option.print_log
95    option.save_log = config.option.save_log
96    option.unsafe = config.option.unsafe
97    option.user = config.option.user
98    option.restart = config.option.restart
99
100    option.generated_tests = {}
101    option.current_dir = os.path.abspath(
102        os.path.join(os.path.dirname(__file__), os.pardir)
103    )
104    option.test_dir = option.current_dir + '/test'
105    option.architecture = platform.architecture()[0]
106    option.system = platform.system()
107
108    option.cache_dir = tempfile.mkdtemp(prefix='unit-test-cache-')
109    public_dir(option.cache_dir)
110
111    # set stdout to non-blocking
112
113    if option.detailed or option.print_log:
114        fcntl.fcntl(sys.stdout.fileno(), fcntl.F_SETFL, 0)
115
116
117def print_log_on_assert(func):
118    def inner_function(*args, **kwargs):
119        try:
120            func(*args, **kwargs)
121        except AssertionError as e:
122            _print_log(kwargs.get('log', None))
123            raise e
124
125    return inner_function
126
127
128def pytest_generate_tests(metafunc):
129    cls = metafunc.cls
130    if (
131        not hasattr(cls, 'application_type')
132        or cls.application_type == None
133        or cls.application_type == 'external'
134    ):
135        return
136
137    type = cls.application_type
138
139    def generate_tests(versions):
140        metafunc.fixturenames.append('tmp_ct')
141        metafunc.parametrize('tmp_ct', versions)
142
143        for version in versions:
144            option.generated_tests[
145                metafunc.function.__name__ + '[{}]'.format(version)
146            ] = (type + ' ' + version)
147
148    # take available module from option and generate tests for each version
149
150    for module, prereq_version in cls.prerequisites['modules'].items():
151        if module in option.available['modules']:
152            available_versions = option.available['modules'][module]
153
154            if prereq_version == 'all':
155                generate_tests(available_versions)
156
157            elif prereq_version == 'any':
158                option.generated_tests[metafunc.function.__name__] = (
159                    type + ' ' + available_versions[0]
160                )
161            elif callable(prereq_version):
162                generate_tests(
163                    list(filter(prereq_version, available_versions))
164                )
165
166            else:
167                raise ValueError(
168                    """
169Unexpected prerequisite version "%s" for module "%s" in %s.
170'all', 'any' or callable expected."""
171                    % (str(prereq_version), module, str(cls))
172                )
173
174
175def pytest_sessionstart(session):
176    option.available = {'modules': {}, 'features': {}}
177
178    unit = unit_run()
179
180    # read unit.log
181
182    for i in range(50):
183        with open(Log.get_path(), 'r') as f:
184            log = f.read()
185            m = re.search('controller started', log)
186
187            if m is None:
188                time.sleep(0.1)
189            else:
190                break
191
192    if m is None:
193        _print_log(log)
194        exit("Unit is writing log too long")
195
196    # discover available modules from unit.log
197
198    for module in re.findall(r'module: ([a-zA-Z]+) (.*) ".*"$', log, re.M):
199        versions = option.available['modules'].setdefault(module[0], [])
200        if module[1] not in versions:
201            versions.append(module[1])
202
203    # discover modules from check
204
205    option.available['modules']['openssl'] = check_openssl(unit['unitd'])
206    option.available['modules']['go'] = check_go()
207    option.available['modules']['node'] = check_node(option.current_dir)
208    option.available['modules']['regex'] = check_regex(unit['unitd'])
209
210    # remove None values
211
212    option.available['modules'] = {
213        k: v for k, v in option.available['modules'].items() if v is not None
214    }
215
216    check_chroot()
217    check_isolation()
218
219    _clear_conf(unit['temp_dir'] + '/control.unit.sock')
220
221    unit_stop()
222
223    _check_alerts()
224
225    if option.restart:
226        shutil.rmtree(unit_instance['temp_dir'])
227
228
229@pytest.hookimpl(tryfirst=True, hookwrapper=True)
230def pytest_runtest_makereport(item, call):
231    # execute all other hooks to obtain the report object
232    outcome = yield
233    rep = outcome.get_result()
234
235    # set a report attribute for each phase of a call, which can
236    # be "setup", "call", "teardown"
237
238    setattr(item, "rep_" + rep.when, rep)
239
240
241@pytest.fixture(scope='class', autouse=True)
242def check_prerequisites(request):
243    cls = request.cls
244    missed = []
245
246    # check modules
247
248    if 'modules' in cls.prerequisites:
249        available_modules = list(option.available['modules'].keys())
250
251        for module in cls.prerequisites['modules']:
252            if module in available_modules:
253                continue
254
255            missed.append(module)
256
257    if missed:
258        pytest.skip('Unit has no ' + ', '.join(missed) + ' module(s)')
259
260    # check features
261
262    if 'features' in cls.prerequisites:
263        available_features = list(option.available['features'].keys())
264
265        for feature in cls.prerequisites['features']:
266            if feature in available_features:
267                continue
268
269            missed.append(feature)
270
271    if missed:
272        pytest.skip(', '.join(missed) + ' feature(s) not supported')
273
274
275@pytest.fixture(autouse=True)
276def run(request):
277    unit = unit_run()
278
279    option.skip_alerts = [
280        r'read signalfd\(4\) failed',
281        r'sendmsg.+failed',
282        r'recvmsg.+failed',
283    ]
284    option.skip_sanitizer = False
285
286    _fds_info['main']['skip'] = False
287    _fds_info['router']['skip'] = False
288    _fds_info['controller']['skip'] = False
289
290    yield
291
292    # stop unit
293
294    error_stop_unit = unit_stop()
295    error_stop_processes = stop_processes()
296
297    # prepare log
298
299    with Log.open(encoding='utf-8') as f:
300        log = f.read()
301        Log.set_pos(f.tell())
302
303    if not option.save_log and option.restart:
304        shutil.rmtree(unit['temp_dir'])
305        Log.set_pos(0)
306
307    # clean temp_dir before the next test
308
309    if not option.restart:
310        _clear_conf(unit['temp_dir'] + '/control.unit.sock', log=log)
311
312        for item in os.listdir(unit['temp_dir']):
313            if item not in [
314                'control.unit.sock',
315                'state',
316                'unit.pid',
317                'unit.log',
318            ]:
319                path = os.path.join(unit['temp_dir'], item)
320
321                public_dir(path)
322
323                if os.path.isfile(path) or stat.S_ISSOCK(
324                    os.stat(path).st_mode
325                ):
326                    os.remove(path)
327                else:
328                    for attempt in range(10):
329                        try:
330                            shutil.rmtree(path)
331                            break
332                        except OSError as err:
333                            if err.errno != 16:
334                                raise
335                            time.sleep(1)
336
337    # check descriptors
338
339    _check_fds(log=log)
340
341    # print unit.log in case of error
342
343    if hasattr(request.node, 'rep_call') and request.node.rep_call.failed:
344        _print_log(log)
345
346    if error_stop_unit or error_stop_processes:
347        _print_log(log)
348
349    # check unit.log for errors
350
351    assert error_stop_unit is None, 'stop unit'
352    assert error_stop_processes is None, 'stop processes'
353
354    _check_alerts(log=log)
355
356
357def unit_run(state_dir=None):
358    global unit_instance
359
360    if not option.restart and 'unitd' in unit_instance:
361        return unit_instance
362
363    build_dir = option.current_dir + '/build'
364    unitd = build_dir + '/unitd'
365
366    if not os.path.isfile(unitd):
367        exit('Could not find unit')
368
369    temp_dir = tempfile.mkdtemp(prefix='unit-test-')
370    public_dir(temp_dir)
371
372    if oct(stat.S_IMODE(os.stat(build_dir).st_mode)) != '0o777':
373        public_dir(build_dir)
374
375    state = temp_dir + '/state' if state_dir is None else state_dir
376    if not os.path.isdir(state):
377        os.mkdir(state)
378
379    unitd_args = [
380        unitd,
381        '--no-daemon',
382        '--modules',
383        build_dir,
384        '--state',
385        state,
386        '--pid',
387        temp_dir + '/unit.pid',
388        '--log',
389        temp_dir + '/unit.log',
390        '--control',
391        'unix:' + temp_dir + '/control.unit.sock',
392        '--tmp',
393        temp_dir,
394    ]
395
396    if option.user:
397        unitd_args.extend(['--user', option.user])
398
399    with open(temp_dir + '/unit.log', 'w') as log:
400        unit_instance['process'] = subprocess.Popen(unitd_args, stderr=log)
401
402    Log.temp_dir = temp_dir
403
404    if not waitforfiles(temp_dir + '/control.unit.sock'):
405        _print_log()
406        exit('Could not start unit')
407
408    unit_instance['temp_dir'] = temp_dir
409    unit_instance['control_sock'] = temp_dir + '/control.unit.sock'
410    unit_instance['unitd'] = unitd
411
412    option.temp_dir = temp_dir
413
414    with open(temp_dir + '/unit.pid', 'r') as f:
415        unit_instance['pid'] = f.read().rstrip()
416
417    if state_dir is None:
418        _clear_conf(unit_instance['temp_dir'] + '/control.unit.sock')
419
420    _fds_info['main']['fds'] = _count_fds(unit_instance['pid'])
421
422    router = _fds_info['router']
423    router['pid'] = pid_by_name(router['name'])
424    router['fds'] = _count_fds(router['pid'])
425
426    controller = _fds_info['controller']
427    controller['pid'] = pid_by_name(controller['name'])
428    controller['fds'] = _count_fds(controller['pid'])
429
430    return unit_instance
431
432
433def unit_stop():
434    if not option.restart:
435        if inspect.stack()[1].function.startswith('test_'):
436            pytest.skip('no restart mode')
437
438        return
439
440    p = unit_instance['process']
441
442    if p.poll() is not None:
443        return
444
445    p.send_signal(signal.SIGQUIT)
446
447    try:
448        retcode = p.wait(15)
449        if retcode:
450            return 'Child process terminated with code ' + str(retcode)
451
452    except KeyboardInterrupt:
453        p.kill()
454        raise
455
456    except:
457        p.kill()
458        return 'Could not terminate unit'
459
460
461@print_log_on_assert
462def _check_alerts(*, log=None):
463    if log is None:
464        with Log.open(encoding='utf-8') as f:
465            log = f.read()
466
467    found = False
468
469    alerts = re.findall(r'.+\[alert\].+', log)
470
471    if alerts:
472        print('\nAll alerts/sanitizer errors found in log:')
473        [print(alert) for alert in alerts]
474        found = True
475
476    if option.skip_alerts:
477        for skip in option.skip_alerts:
478            alerts = [al for al in alerts if re.search(skip, al) is None]
479
480    assert not alerts, 'alert(s)'
481
482    if not option.skip_sanitizer:
483        sanitizer_errors = re.findall('.+Sanitizer.+', log)
484
485        assert not sanitizer_errors, 'sanitizer error(s)'
486
487    if found:
488        print('skipped.')
489
490
491def _print_log(log=None):
492    path = Log.get_path()
493
494    print('Path to unit.log:\n' + path + '\n')
495
496    if option.print_log:
497        os.set_blocking(sys.stdout.fileno(), True)
498        sys.stdout.flush()
499
500        if log is None:
501            with open(path, 'r', encoding='utf-8', errors='ignore') as f:
502                shutil.copyfileobj(f, sys.stdout)
503        else:
504            sys.stdout.write(log)
505
506
507@print_log_on_assert
508def _clear_conf(sock, *, log=None):
509    resp = http.put(
510        url='/config',
511        sock_type='unix',
512        addr=sock,
513        body=json.dumps({"listeners": {}, "applications": {}}),
514    )['body']
515
516    assert 'success' in resp, 'clear conf'
517
518    if 'openssl' not in option.available['modules']:
519        return
520
521    try:
522        certs = json.loads(
523            http.get(url='/certificates', sock_type='unix', addr=sock,)['body']
524        ).keys()
525
526    except json.JSONDecodeError:
527        pytest.fail('Can\'t parse certificates list.')
528
529    for cert in certs:
530        resp = http.delete(
531            url='/certificates/' + cert, sock_type='unix', addr=sock,
532        )['body']
533
534        assert 'success' in resp, 'remove certificate'
535
536
537@print_log_on_assert
538def _check_fds(*, log=None):
539    def waitforfds(diff):
540        for i in range(600):
541            fds_diff = diff()
542
543            if fds_diff <= option.fds_threshold:
544                break
545
546            time.sleep(0.1)
547
548        return fds_diff
549
550    ps = _fds_info['main']
551    if not ps['skip']:
552        fds_diff = waitforfds(
553            lambda: _count_fds(unit_instance['pid']) - ps['fds']
554        )
555        ps['fds'] += fds_diff
556
557        assert (
558            fds_diff <= option.fds_threshold
559        ), 'descriptors leak main process'
560
561    else:
562        ps['fds'] = _count_fds(unit_instance['pid'])
563
564    for name in ['controller', 'router']:
565        ps = _fds_info[name]
566        ps_pid = ps['pid']
567        ps['pid'] = pid_by_name(ps['name'])
568
569        if not ps['skip']:
570            fds_diff = waitforfds(lambda: _count_fds(ps['pid']) - ps['fds'])
571            ps['fds'] += fds_diff
572
573            if not option.restart:
574                assert ps['pid'] == ps_pid, 'same pid %s' % name
575
576            assert fds_diff <= option.fds_threshold, (
577                'descriptors leak %s' % name
578            )
579
580        else:
581            ps['fds'] = _count_fds(ps['pid'])
582
583
584def _count_fds(pid):
585    procfile = '/proc/%s/fd' % pid
586    if os.path.isdir(procfile):
587        return len(os.listdir(procfile))
588
589    try:
590        out = subprocess.check_output(
591            ['procstat', '-f', pid], stderr=subprocess.STDOUT,
592        ).decode()
593        return len(out.splitlines())
594
595    except (FileNotFoundError, TypeError, subprocess.CalledProcessError):
596        pass
597
598    try:
599        out = subprocess.check_output(
600            ['lsof', '-n', '-p', pid], stderr=subprocess.STDOUT,
601        ).decode()
602        return len(out.splitlines())
603
604    except (FileNotFoundError, TypeError, subprocess.CalledProcessError):
605        pass
606
607    return 0
608
609
610def run_process(target, *args):
611    global _processes
612
613    process = Process(target=target, args=args)
614    process.start()
615
616    _processes.append(process)
617
618
619def stop_processes():
620    if not _processes:
621        return
622
623    fail = False
624    for process in _processes:
625        if process.is_alive():
626            process.terminate()
627            process.join(timeout=15)
628
629            if process.is_alive():
630                fail = True
631
632    if fail:
633        return 'Fail to stop process(es)'
634
635
636def pid_by_name(name):
637    output = subprocess.check_output(['ps', 'ax', '-O', 'ppid']).decode()
638    m = re.search(
639        r'\s*(\d+)\s*' + str(unit_instance['pid']) + r'.*' + name, output
640    )
641    return None if m is None else m.group(1)
642
643
644def find_proc(name, ps_output):
645    return re.findall(str(unit_instance['pid']) + r'.*' + name, ps_output)
646
647
648@pytest.fixture()
649def skip_alert():
650    def _skip(*alerts):
651        option.skip_alerts.extend(alerts)
652
653    return _skip
654
655
656@pytest.fixture()
657def skip_fds_check():
658    def _skip(main=False, router=False, controller=False):
659        _fds_info['main']['skip'] = main
660        _fds_info['router']['skip'] = router
661        _fds_info['controller']['skip'] = controller
662
663    return _skip
664
665
666@pytest.fixture
667def temp_dir(request):
668    return unit_instance['temp_dir']
669
670
671@pytest.fixture
672def is_unsafe(request):
673    return request.config.getoption("--unsafe")
674
675
676@pytest.fixture
677def is_su(request):
678    return os.geteuid() == 0
679
680
681@pytest.fixture
682def unit_pid(request):
683    return unit_instance['process'].pid
684
685
686def pytest_sessionfinish(session):
687    if not option.restart and option.save_log:
688        print('Path to unit.log:\n' + Log.get_path() + '\n')
689
690    option.restart = True
691
692    unit_stop()
693
694    public_dir(option.cache_dir)
695    shutil.rmtree(option.cache_dir)
696
697    if not option.save_log and os.path.isdir(option.temp_dir):
698        public_dir(option.temp_dir)
699        shutil.rmtree(option.temp_dir)
700