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