xref: /unit/test/conftest.py (revision 1971:3410f9d2a662)
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.current_dir, unit['temp_dir'], option.test_dir
208    )
209    option.available['modules']['node'] = check_node(option.current_dir)
210    option.available['modules']['regex'] = check_regex(unit['unitd'])
211
212    # remove None values
213
214    option.available['modules'] = {
215        k: v for k, v in option.available['modules'].items() if v is not None
216    }
217
218    check_chroot()
219    check_isolation()
220
221    _clear_conf(unit['temp_dir'] + '/control.unit.sock')
222
223    unit_stop()
224
225    _check_alerts()
226
227    if option.restart:
228        shutil.rmtree(unit_instance['temp_dir'])
229
230
231@pytest.hookimpl(tryfirst=True, hookwrapper=True)
232def pytest_runtest_makereport(item, call):
233    # execute all other hooks to obtain the report object
234    outcome = yield
235    rep = outcome.get_result()
236
237    # set a report attribute for each phase of a call, which can
238    # be "setup", "call", "teardown"
239
240    setattr(item, "rep_" + rep.when, rep)
241
242
243@pytest.fixture(scope='class', autouse=True)
244def check_prerequisites(request):
245    cls = request.cls
246    missed = []
247
248    # check modules
249
250    if 'modules' in cls.prerequisites:
251        available_modules = list(option.available['modules'].keys())
252
253        for module in cls.prerequisites['modules']:
254            if module in available_modules:
255                continue
256
257            missed.append(module)
258
259    if missed:
260        pytest.skip('Unit has no ' + ', '.join(missed) + ' module(s)')
261
262    # check features
263
264    if 'features' in cls.prerequisites:
265        available_features = list(option.available['features'].keys())
266
267        for feature in cls.prerequisites['features']:
268            if feature in available_features:
269                continue
270
271            missed.append(feature)
272
273    if missed:
274        pytest.skip(', '.join(missed) + ' feature(s) not supported')
275
276
277@pytest.fixture(autouse=True)
278def run(request):
279    unit = unit_run()
280
281    option.skip_alerts = [
282        r'read signalfd\(4\) failed',
283        r'sendmsg.+failed',
284        r'recvmsg.+failed',
285    ]
286    option.skip_sanitizer = False
287
288    _fds_info['main']['skip'] = False
289    _fds_info['router']['skip'] = False
290    _fds_info['controller']['skip'] = False
291
292    yield
293
294    # stop unit
295
296    error_stop_unit = unit_stop()
297    error_stop_processes = stop_processes()
298
299    # prepare log
300
301    with Log.open(encoding='utf-8') as f:
302        log = f.read()
303        Log.set_pos(f.tell())
304
305    if not option.save_log and option.restart:
306        shutil.rmtree(unit['temp_dir'])
307        Log.set_pos(0)
308
309    # clean temp_dir before the next test
310
311    if not option.restart:
312        _clear_conf(unit['temp_dir'] + '/control.unit.sock', log=log)
313
314        for item in os.listdir(unit['temp_dir']):
315            if item not in [
316                'control.unit.sock',
317                'state',
318                'unit.pid',
319                'unit.log',
320            ]:
321                path = os.path.join(unit['temp_dir'], item)
322
323                public_dir(path)
324
325                if os.path.isfile(path) or stat.S_ISSOCK(
326                    os.stat(path).st_mode
327                ):
328                    os.remove(path)
329                else:
330                    for attempt in range(10):
331                        try:
332                            shutil.rmtree(path)
333                            break
334                        except OSError as err:
335                            if err.errno != 16:
336                                raise
337                            time.sleep(1)
338
339    # check descriptors
340
341    _check_fds(log=log)
342
343    # print unit.log in case of error
344
345    if hasattr(request.node, 'rep_call') and request.node.rep_call.failed:
346        _print_log(log)
347
348    if error_stop_unit or error_stop_processes:
349        _print_log(log)
350
351    # check unit.log for errors
352
353    assert error_stop_unit is None, 'stop unit'
354    assert error_stop_processes is None, 'stop processes'
355
356    _check_alerts(log=log)
357
358
359def unit_run():
360    global unit_instance
361
362    if not option.restart and 'unitd' in unit_instance:
363        return unit_instance
364
365    build_dir = option.current_dir + '/build'
366    unitd = build_dir + '/unitd'
367
368    if not os.path.isfile(unitd):
369        exit('Could not find unit')
370
371    temp_dir = tempfile.mkdtemp(prefix='unit-test-')
372    public_dir(temp_dir)
373
374    if oct(stat.S_IMODE(os.stat(build_dir).st_mode)) != '0o777':
375        public_dir(build_dir)
376
377    os.mkdir(temp_dir + '/state')
378
379    unitd_args = [
380        unitd,
381        '--no-daemon',
382        '--modules',
383        build_dir,
384        '--state',
385        temp_dir + '/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    _clear_conf(unit_instance['temp_dir'] + '/control.unit.sock')
418
419    _fds_info['main']['fds'] = _count_fds(unit_instance['pid'])
420
421    router = _fds_info['router']
422    router['pid'] = pid_by_name(router['name'])
423    router['fds'] = _count_fds(router['pid'])
424
425    controller = _fds_info['controller']
426    controller['pid'] = pid_by_name(controller['name'])
427    controller['fds'] = _count_fds(controller['pid'])
428
429    return unit_instance
430
431
432def unit_stop():
433    if not option.restart:
434        if inspect.stack()[1].function.startswith('test_'):
435            pytest.skip('no restart mode')
436
437        return
438
439    p = unit_instance['process']
440
441    if p.poll() is not None:
442        return
443
444    p.send_signal(signal.SIGQUIT)
445
446    try:
447        retcode = p.wait(15)
448        if retcode:
449            return 'Child process terminated with code ' + str(retcode)
450
451    except KeyboardInterrupt:
452        p.kill()
453        raise
454
455    except:
456        p.kill()
457        return 'Could not terminate unit'
458
459
460@print_log_on_assert
461def _check_alerts(*, log=None):
462    if log is None:
463        with Log.open(encoding='utf-8') as f:
464            log = f.read()
465
466    found = False
467
468    alerts = re.findall(r'.+\[alert\].+', log)
469
470    if alerts:
471        print('\nAll alerts/sanitizer errors found in log:')
472        [print(alert) for alert in alerts]
473        found = True
474
475    if option.skip_alerts:
476        for skip in option.skip_alerts:
477            alerts = [al for al in alerts if re.search(skip, al) is None]
478
479    assert not alerts, 'alert(s)'
480
481    if not option.skip_sanitizer:
482        sanitizer_errors = re.findall('.+Sanitizer.+', log)
483
484        assert not sanitizer_errors, 'sanitizer error(s)'
485
486    if found:
487        print('skipped.')
488
489
490def _print_log(log=None):
491    path = Log.get_path()
492
493    print('Path to unit.log:\n' + path + '\n')
494
495    if option.print_log:
496        os.set_blocking(sys.stdout.fileno(), True)
497        sys.stdout.flush()
498
499        if log is None:
500            with open(path, 'r', encoding='utf-8', errors='ignore') as f:
501                shutil.copyfileobj(f, sys.stdout)
502        else:
503            sys.stdout.write(log)
504
505
506@print_log_on_assert
507def _clear_conf(sock, *, log=None):
508    resp = http.put(
509        url='/config',
510        sock_type='unix',
511        addr=sock,
512        body=json.dumps({"listeners": {}, "applications": {}}),
513    )['body']
514
515    assert 'success' in resp, 'clear conf'
516
517    if 'openssl' not in option.available['modules']:
518        return
519
520    try:
521        certs = json.loads(
522            http.get(url='/certificates', sock_type='unix', addr=sock,)['body']
523        ).keys()
524
525    except json.JSONDecodeError:
526        pytest.fail('Can\'t parse certificates list.')
527
528    for cert in certs:
529        resp = http.delete(
530            url='/certificates/' + cert, sock_type='unix', addr=sock,
531        )['body']
532
533        assert 'success' in resp, 'remove certificate'
534
535
536@print_log_on_assert
537def _check_fds(*, log=None):
538    def waitforfds(diff):
539        for i in range(600):
540            fds_diff = diff()
541
542            if fds_diff <= option.fds_threshold:
543                break
544
545            time.sleep(0.1)
546
547        return fds_diff
548
549    ps = _fds_info['main']
550    if not ps['skip']:
551        fds_diff = waitforfds(
552            lambda: _count_fds(unit_instance['pid']) - ps['fds']
553        )
554        ps['fds'] += fds_diff
555
556        assert (
557            fds_diff <= option.fds_threshold
558        ), 'descriptors leak main process'
559
560    else:
561        ps['fds'] = _count_fds(unit_instance['pid'])
562
563    for name in ['controller', 'router']:
564        ps = _fds_info[name]
565        ps_pid = ps['pid']
566        ps['pid'] = pid_by_name(ps['name'])
567
568        if not ps['skip']:
569            fds_diff = waitforfds(lambda: _count_fds(ps['pid']) - ps['fds'])
570            ps['fds'] += fds_diff
571
572            if not option.restart:
573                assert ps['pid'] == ps_pid, 'same pid %s' % name
574
575            assert fds_diff <= option.fds_threshold, (
576                'descriptors leak %s' % name
577            )
578
579        else:
580            ps['fds'] = _count_fds(ps['pid'])
581
582
583def _count_fds(pid):
584    procfile = '/proc/%s/fd' % pid
585    if os.path.isdir(procfile):
586        return len(os.listdir(procfile))
587
588    try:
589        out = subprocess.check_output(
590            ['procstat', '-f', pid], stderr=subprocess.STDOUT,
591        ).decode()
592        return len(out.splitlines())
593
594    except (FileNotFoundError, TypeError, subprocess.CalledProcessError):
595        pass
596
597    try:
598        out = subprocess.check_output(
599            ['lsof', '-n', '-p', pid], stderr=subprocess.STDOUT,
600        ).decode()
601        return len(out.splitlines())
602
603    except (FileNotFoundError, TypeError, subprocess.CalledProcessError):
604        pass
605
606    return 0
607
608
609def run_process(target, *args):
610    global _processes
611
612    process = Process(target=target, args=args)
613    process.start()
614
615    _processes.append(process)
616
617
618def stop_processes():
619    if not _processes:
620        return
621
622    fail = False
623    for process in _processes:
624        if process.is_alive():
625            process.terminate()
626            process.join(timeout=15)
627
628            if process.is_alive():
629                fail = True
630
631    if fail:
632        return 'Fail to stop process(es)'
633
634
635def pid_by_name(name):
636    output = subprocess.check_output(['ps', 'ax', '-O', 'ppid']).decode()
637    m = re.search(
638        r'\s*(\d+)\s*' + str(unit_instance['pid']) + r'.*' + name, output
639    )
640    return None if m is None else m.group(1)
641
642
643def find_proc(name, ps_output):
644    return re.findall(str(unit_instance['pid']) + r'.*' + name, ps_output)
645
646
647@pytest.fixture()
648def skip_alert():
649    def _skip(*alerts):
650        option.skip_alerts.extend(alerts)
651
652    return _skip
653
654
655@pytest.fixture()
656def skip_fds_check():
657    def _skip(main=False, router=False, controller=False):
658        _fds_info['main']['skip'] = main
659        _fds_info['router']['skip'] = router
660        _fds_info['controller']['skip'] = controller
661
662    return _skip
663
664
665@pytest.fixture
666def temp_dir(request):
667    return unit_instance['temp_dir']
668
669
670@pytest.fixture
671def is_unsafe(request):
672    return request.config.getoption("--unsafe")
673
674
675@pytest.fixture
676def is_su(request):
677    return os.geteuid() == 0
678
679
680@pytest.fixture
681def unit_pid(request):
682    return unit_instance['process'].pid
683
684
685def pytest_sessionfinish(session):
686    if not option.restart and option.save_log:
687        print('Path to unit.log:\n' + Log.get_path() + '\n')
688
689    option.restart = True
690
691    unit_stop()
692
693    public_dir(option.cache_dir)
694    shutil.rmtree(option.cache_dir)
695
696    if not option.save_log and os.path.isdir(option.temp_dir):
697        public_dir(option.temp_dir)
698        shutil.rmtree(option.temp_dir)
699