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