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