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