2024-04-19 10:17:58 +03:00
#!/usr/bin/python3
# SPDX-License-Identifier: LGPL-2.1-or-later
2024-11-23 14:59:32 +03:00
""" Test wrapper command for driving integration tests. """
2024-04-19 10:17:58 +03:00
import argparse
2024-06-04 11:54:22 +03:00
import base64
import dataclasses
2024-05-07 13:24:51 +03:00
import json
2024-04-19 10:17:58 +03:00
import os
2024-11-23 00:51:45 +03:00
import re
2024-04-19 10:17:58 +03:00
import shlex
import subprocess
import sys
2024-06-04 11:54:22 +03:00
import tempfile
2024-04-19 10:17:58 +03:00
import textwrap
from pathlib import Path
EMERGENCY_EXIT_DROPIN = """ \
[ Unit ]
Wants = emergency - exit . service
"""
EMERGENCY_EXIT_SERVICE = """ \
[ Unit ]
DefaultDependencies = no
Conflicts = shutdown . target
Conflicts = rescue . service
Before = shutdown . target
Before = rescue . service
FailureAction = exit
[ Service ]
ExecStart = false
"""
2024-06-04 11:54:22 +03:00
def sandbox ( args : argparse . Namespace ) - > list [ str ] :
return [
args . mkosi ,
' --directory ' , os . fspath ( args . meson_source_dir ) ,
' --extra-search-path ' , os . fspath ( args . meson_build_dir ) ,
' sandbox ' ,
] # fmt: skip
@dataclasses.dataclass ( frozen = True )
class Summary :
distribution : str
release : str
architecture : str
builddir : Path
environment : dict [ str , str ]
@classmethod
def get ( cls , args : argparse . Namespace ) - > ' Summary ' :
j = json . loads (
subprocess . run (
[
args . mkosi ,
' --directory ' , os . fspath ( args . meson_source_dir ) ,
' --json ' ,
' summary ' ,
] ,
stdout = subprocess . PIPE ,
text = True ,
) . stdout
) # fmt: skip
return Summary (
distribution = j [ ' Images ' ] [ - 1 ] [ ' Distribution ' ] ,
release = j [ ' Images ' ] [ - 1 ] [ ' Release ' ] ,
architecture = j [ ' Images ' ] [ - 1 ] [ ' Architecture ' ] ,
builddir = Path ( j [ ' Images ' ] [ - 1 ] [ ' BuildDirectory ' ] ) ,
environment = j [ ' Images ' ] [ - 1 ] [ ' Environment ' ] ,
)
2024-11-23 00:51:45 +03:00
def process_coredumps ( args : argparse . Namespace , journal_file : Path ) - > bool :
# Collect executable paths of all coredumps and filter out the expected ones.
if args . coredump_exclude_regex :
exclude_regex = re . compile ( args . coredump_exclude_regex )
else :
exclude_regex = None
result = subprocess . run (
2024-06-04 11:54:22 +03:00
sandbox ( args ) + [
2024-11-23 00:51:45 +03:00
' coredumpctl ' ,
' --file ' , journal_file ,
' --json=short ' ,
] ,
stdout = subprocess . PIPE ,
text = True ,
) # fmt: skip
# coredumpctl returns a non-zero exit status if there are no coredumps.
if result . returncode != 0 :
return False
coredumps = json . loads ( result . stdout )
coredumps = [
coredump for coredump in coredumps if not exclude_regex or not exclude_regex . search ( coredump [ ' exe ' ] )
]
if not coredumps :
return False
subprocess . run (
2024-06-04 11:54:22 +03:00
sandbox ( args ) + [
2024-11-23 00:51:45 +03:00
' coredumpctl ' ,
' --file ' , journal_file ,
' --no-pager ' ,
' info ' ,
* ( coredump [ ' exe ' ] for coredump in coredumps ) ,
] ,
check = True ,
) # fmt: skip
return True
2024-12-07 07:36:39 +03:00
def process_sanitizer_report ( args : argparse . Namespace , journal_file : Path ) - > bool :
# Collect sanitizer reports from the journal file.
if args . sanitizer_exclude_regex :
exclude_regex = re . compile ( args . sanitizer_exclude_regex )
else :
exclude_regex = None
total = 0
fatal = 0
asan = 0
ubsan = 0
msan = 0
# Internal errors:
# ==2554==LeakSanitizer has encountered a fatal error.
# ==2554==HINT: For debugging, try setting environment variable LSAN_OPTIONS=verbosity=1:log_threads=1
# ==2554==HINT: LeakSanitizer does not work under ptrace (strace, gdb, etc)
fatal_begin = re . compile ( r ' ==[0-9]+==.+? \ w+Sanitizer has encountered a fatal error ' )
fatal_end = re . compile ( r ' ==[0-9]+==HINT: \ s+ \ w+Sanitizer ' )
# 'Standard' errors:
standard_begin = re . compile ( r ' ([0-9]+: runtime error|==[0-9]+==.+? \ w+Sanitizer) ' )
standard_end = re . compile ( r ' SUMMARY: \ s+( \ w+)Sanitizer ' )
# extract COMM
find_comm = re . compile ( r ' ^ \ [[.0-9 ]+? \ ] \ s(.*?:) \ s ' )
with subprocess . Popen (
sandbox ( args ) + [
' journalctl ' ,
' --output ' , ' short-monotonic ' ,
' --no-hostname ' ,
' --quiet ' ,
' --priority ' , ' info ' ,
' --file ' , journal_file ,
] ,
stdout = subprocess . PIPE ,
text = True ,
) as p : # fmt: skip
assert p . stdout
is_fatal = False
is_standard = False
comm = None
while True :
line = p . stdout . readline ( )
if not line and p . poll ( ) is not None :
break
if not is_standard and fatal_begin . search ( line ) :
m = find_comm . search ( line )
if m :
if exclude_regex and exclude_regex . search ( m . group ( 1 ) ) :
continue
comm = m . group ( 1 )
sys . stderr . write ( line )
is_fatal = True
total + = 1
fatal + = 1
continue
if is_fatal :
if comm and comm not in line :
continue
sys . stderr . write ( line )
if fatal_end . search ( line ) :
print ( file = sys . stderr )
is_fatal = False
comm = None
continue
if standard_begin . search ( line ) :
m = find_comm . search ( line )
if m :
if exclude_regex and exclude_regex . search ( m . group ( 1 ) ) :
continue
comm = m . group ( 1 )
sys . stderr . write ( line )
is_standard = True
total + = 1
continue
if is_standard :
if comm and comm not in line :
continue
sys . stderr . write ( line )
kind = standard_end . search ( line )
if kind :
print ( file = sys . stderr )
is_standard = False
comm = None
t = kind . group ( 1 )
if t == ' Address ' :
asan + = 1
elif t == ' UndefinedBehavior ' :
ubsan + = 1
elif t == ' Memory ' :
msan + = 1
if total > 0 :
print (
f ' Found { total } sanitizer issues ( { fatal } internal, { asan } asan, { ubsan } ubsan, { msan } msan). ' ,
file = sys . stderr ,
)
else :
print ( ' No sanitizer issues found. ' , file = sys . stderr )
return total > 0
2024-06-04 11:54:22 +03:00
def process_coverage ( args : argparse . Namespace , summary : Summary , name : str , journal_file : Path ) - > None :
coverage = subprocess . run (
sandbox ( args ) + [
' journalctl ' ,
' --file ' , journal_file ,
' --field=COVERAGE_TAR ' ,
] ,
stdout = subprocess . PIPE ,
text = True ,
check = True ,
) . stdout # fmt: skip
( args . meson_build_dir / ' test/coverage ' ) . mkdir ( exist_ok = True )
initial = args . meson_build_dir / ' test/coverage/initial.coverage-info '
output = args . meson_build_dir / f ' test/coverage/ { name } .coverage-info '
for b64 in coverage . splitlines ( ) :
tarball = base64 . b64decode ( b64 )
with tempfile . TemporaryDirectory ( prefix = ' coverage- ' ) as tmp :
subprocess . run (
sandbox ( args ) + [
' tar ' ,
' --extract ' ,
' --file ' , ' - ' ,
' --directory ' , tmp ,
' --keep-directory-symlink ' ,
' --no-overwrite-dir ' ,
' --zstd ' ,
] ,
input = tarball ,
check = True ,
) # fmt: skip
for p in Path ( tmp ) . iterdir ( ) :
if not p . name . startswith ( ' # ' ) :
continue
dst = Path ( tmp ) / p . name . replace ( ' # ' , ' / ' ) . lstrip ( ' / ' )
dst . parent . mkdir ( parents = True , exist_ok = True )
p . rename ( dst )
subprocess . run (
sandbox ( args ) + [
' find ' ,
tmp ,
' -name ' , ' *.gcda ' ,
' -size ' , ' 0 ' ,
' -delete ' ,
] ,
input = tarball ,
check = True ,
) # fmt: skip
subprocess . run (
sandbox ( args )
+ [
' rsync ' ,
' --archive ' ,
' --prune-empty-dirs ' ,
' --include=*/ ' ,
' --include=*.gcno ' ,
' --exclude=* ' ,
f ' { os . fspath ( args . meson_build_dir / summary . builddir ) } / ' ,
os . fspath ( Path ( tmp ) / ' work/build ' ) ,
] ,
check = True ,
)
subprocess . run (
sandbox ( args )
+ [
' lcov ' ,
* (
[
' --gcov-tool ' , ' llvm-cov ' ,
' --gcov-tool ' , ' gcov ' ,
]
if summary . environment . get ( ' LLVM ' , ' 0 ' ) == ' 1 '
else [ ]
) ,
' --directory ' , tmp ,
' --base-directory ' , ' src/ ' ,
' --capture ' ,
' --exclude ' , ' *.gperf ' ,
' --output-file ' , f ' { output } .new ' ,
' --ignore-errors ' , ' inconsistent,inconsistent,source,negative ' ,
' --substitute ' , ' s#src/src#src#g ' ,
' --no-external ' ,
' --quiet ' ,
] ,
check = True ,
) # fmt: skip
subprocess . run (
sandbox ( args )
+ [
' lcov ' ,
' --ignore-errors ' , ' inconsistent,inconsistent,format,corrupt,empty ' ,
' --add-tracefile ' , output if output . exists ( ) else initial ,
' --add-tracefile ' , f ' { output } .new ' ,
' --output-file ' , output ,
' --quiet ' ,
] ,
check = True ,
) # fmt: skip
Path ( f ' { output } .new ' ) . unlink ( )
print ( f ' Wrote coverage report for { name } to { output } ' , file = sys . stderr )
2024-11-23 15:01:00 +03:00
def main ( ) - > None :
2024-04-19 10:17:58 +03:00
parser = argparse . ArgumentParser ( description = __doc__ )
2024-05-27 12:15:02 +03:00
parser . add_argument ( ' --mkosi ' , required = True )
2024-04-19 10:17:58 +03:00
parser . add_argument ( ' --meson-source-dir ' , required = True , type = Path )
parser . add_argument ( ' --meson-build-dir ' , required = True , type = Path )
2024-05-11 20:40:03 +03:00
parser . add_argument ( ' --name ' , required = True )
2024-05-12 11:50:47 +03:00
parser . add_argument ( ' --unit ' , required = True )
2024-04-30 20:06:00 +03:00
parser . add_argument ( ' --storage ' , required = True )
2024-05-05 19:14:44 +03:00
parser . add_argument ( ' --firmware ' , required = True )
2024-05-07 16:20:44 +03:00
parser . add_argument ( ' --slow ' , action = argparse . BooleanOptionalAction )
2024-05-28 16:54:35 +03:00
parser . add_argument ( ' --vm ' , action = argparse . BooleanOptionalAction )
2024-05-15 11:09:53 +03:00
parser . add_argument ( ' --exit-code ' , required = True , type = int )
2024-11-23 00:51:45 +03:00
parser . add_argument ( ' --coredump-exclude-regex ' , required = True )
2024-12-07 07:36:39 +03:00
parser . add_argument ( ' --sanitizer-exclude-regex ' , required = True )
2024-11-23 14:59:32 +03:00
parser . add_argument ( ' mkosi_args ' , nargs = ' * ' )
2024-04-19 10:17:58 +03:00
args = parser . parse_args ( )
2024-11-23 14:59:32 +03:00
if not bool ( int ( os . getenv ( ' SYSTEMD_INTEGRATION_TESTS ' , ' 0 ' ) ) ) :
print (
f ' SYSTEMD_INTEGRATION_TESTS=1 not found in environment, skipping { args . name } ' ,
file = sys . stderr ,
)
2024-05-07 16:20:44 +03:00
exit ( 77 )
2024-11-23 14:59:32 +03:00
if args . slow and not bool ( int ( os . getenv ( ' SYSTEMD_SLOW_TESTS ' , ' 0 ' ) ) ) :
print (
f ' SYSTEMD_SLOW_TESTS=1 not found in environment, skipping { args . name } ' ,
file = sys . stderr ,
)
2024-05-07 16:20:44 +03:00
exit ( 77 )
2024-11-23 14:59:32 +03:00
if args . vm and bool ( int ( os . getenv ( ' TEST_NO_QEMU ' , ' 0 ' ) ) ) :
print ( f ' TEST_NO_QEMU=1, skipping { args . name } ' , file = sys . stderr )
2024-06-17 16:09:40 +03:00
exit ( 77 )
2024-11-23 14:59:32 +03:00
for s in os . getenv ( ' TEST_SKIP ' , ' ' ) . split ( ) :
2024-09-11 13:01:55 +03:00
if s in args . name :
2024-11-23 14:59:32 +03:00
print ( f ' Skipping { args . name } due to TEST_SKIP ' , file = sys . stderr )
2024-09-11 13:01:55 +03:00
exit ( 77 )
2024-08-15 00:49:10 +03:00
2024-11-23 14:59:32 +03:00
keep_journal = os . getenv ( ' TEST_SAVE_JOURNAL ' , ' fail ' )
shell = bool ( int ( os . getenv ( ' TEST_SHELL ' , ' 0 ' ) ) )
2024-06-04 11:54:22 +03:00
summary = Summary . get ( args )
2024-08-02 16:46:41 +03:00
if shell and not sys . stderr . isatty ( ) :
2024-11-23 14:59:32 +03:00
print (
' --interactive must be passed to meson test to use TEST_SHELL=1 ' ,
file = sys . stderr ,
)
2024-08-02 16:46:41 +03:00
exit ( 1 )
2024-06-24 17:20:11 +03:00
2024-11-23 14:59:32 +03:00
name = args . name + ( f ' - { i } ' if ( i := os . getenv ( ' MESON_TEST_ITERATION ' ) ) else ' ' )
2024-04-19 10:17:58 +03:00
dropin = textwrap . dedent (
""" \
[ Service ]
StandardOutput = journal + console
"""
)
2024-08-02 16:46:41 +03:00
if not shell :
dropin + = textwrap . dedent (
2024-11-23 00:37:34 +03:00
"""
2024-08-02 16:46:41 +03:00
[ Unit ]
SuccessAction = exit
SuccessActionExitStatus = 123
"""
)
2024-11-23 14:59:32 +03:00
if os . getenv ( ' TEST_MATCH_SUBTEST ' ) :
2024-05-03 11:27:58 +03:00
dropin + = textwrap . dedent (
f """
[ Service ]
Environment = TEST_MATCH_SUBTEST = { os . environ [ " TEST_MATCH_SUBTEST " ] }
"""
)
2024-11-23 14:59:32 +03:00
if os . getenv ( ' TEST_MATCH_TESTCASE ' ) :
2024-05-03 11:27:58 +03:00
dropin + = textwrap . dedent (
f """
[ Service ]
Environment = TEST_MATCH_TESTCASE = { os . environ [ " TEST_MATCH_TESTCASE " ] }
"""
)
2024-11-23 00:51:45 +03:00
journal_file = ( args . meson_build_dir / ( f ' test/journal/ { name } .journal ' ) ) . absolute ( )
journal_file . unlink ( missing_ok = True )
2024-04-19 10:17:58 +03:00
if not sys . stderr . isatty ( ) :
dropin + = textwrap . dedent (
"""
[ Unit ]
FailureAction = exit
"""
)
2024-08-02 16:46:41 +03:00
elif not shell :
2024-06-28 14:34:37 +03:00
dropin + = textwrap . dedent (
"""
[ Unit ]
Wants = multi - user . target
"""
)
2024-04-19 10:17:58 +03:00
cmd = [
2024-05-27 12:15:02 +03:00
args . mkosi ,
2024-04-19 10:17:58 +03:00
' --directory ' , os . fspath ( args . meson_source_dir ) ,
' --output-dir ' , os . fspath ( args . meson_build_dir / ' mkosi.output ' ) ,
' --extra-search-path ' , os . fspath ( args . meson_build_dir ) ,
2024-05-03 11:57:50 +03:00
' --machine ' , name ,
2024-04-19 10:17:58 +03:00
' --ephemeral ' ,
* ( [ ' --forward-journal ' , journal_file ] if journal_file else [ ] ) ,
* (
[
2024-11-23 14:59:32 +03:00
' --credential ' , f ' systemd.extra-unit.emergency-exit.service= { shlex . quote ( EMERGENCY_EXIT_SERVICE ) } ' , # noqa: E501
' --credential ' , f ' systemd.unit-dropin.emergency.target= { shlex . quote ( EMERGENCY_EXIT_DROPIN ) } ' ,
2024-04-19 10:17:58 +03:00
]
if not sys . stderr . isatty ( )
else [ ]
) ,
2024-11-23 14:59:32 +03:00
' --credential ' , f ' systemd.unit-dropin. { args . unit } = { shlex . quote ( dropin ) } ' ,
2024-04-25 12:00:15 +03:00
' --runtime-network=none ' ,
2024-04-28 21:46:14 +03:00
' --runtime-scratch=no ' ,
2024-05-12 16:16:37 +03:00
* args . mkosi_args ,
2024-11-23 14:59:32 +03:00
' --qemu-firmware ' ,
args . firmware ,
* ( [ ' --qemu-kvm ' , ' no ' ] if int ( os . getenv ( ' TEST_NO_KVM ' , ' 0 ' ) ) else [ ] ) ,
2024-04-19 10:17:58 +03:00
' --kernel-command-line-extra ' ,
2024-11-23 14:59:32 +03:00
' ' . join (
[
' systemd.hostname=H ' ,
f ' SYSTEMD_UNIT_PATH=/usr/lib/systemd/tests/testdata/ { args . name } .units:/usr/lib/systemd/tests/testdata/units: ' ,
* ( [ f ' systemd.unit= { args . unit } ' ] if not shell else [ ] ) ,
' systemd.mask=systemd-networkd-wait-online.service ' ,
* (
[
' systemd.mask=serial-getty@.service ' ,
' systemd.show_status=error ' ,
' systemd.crash_shell=0 ' ,
' systemd.crash_action=poweroff ' ,
2024-12-06 17:13:16 +03:00
' loglevel=6 ' ,
2024-11-23 14:59:32 +03:00
]
if not sys . stderr . isatty ( )
else [ ]
) ,
]
) ,
2024-05-02 09:52:50 +03:00
' --credential ' , f " journal.storage= { ' persistent ' if sys . stderr . isatty ( ) else args . storage } " ,
2024-07-29 14:40:42 +03:00
* ( [ ' --runtime-build-sources=no ' ] if not sys . stderr . isatty ( ) else [ ] ) ,
2024-12-05 16:01:08 +03:00
' qemu ' if args . vm or os . getuid ( ) != 0 or os . getenv ( ' TEST_PREFER_QEMU ' , ' 0 ' ) == ' 1 ' else ' boot ' ,
2024-11-23 14:59:32 +03:00
] # fmt: skip
2024-04-19 10:17:58 +03:00
2024-04-23 16:13:22 +03:00
result = subprocess . run ( cmd )
2024-05-07 12:50:11 +03:00
2024-10-07 18:48:55 +03:00
# On Debian/Ubuntu we get a lot of random QEMU crashes. Retry once, and then skip if it fails again.
if args . vm and result . returncode == 247 and args . exit_code != 247 :
2024-11-23 15:01:00 +03:00
if journal_file :
journal_file . unlink ( missing_ok = True )
2024-10-07 18:48:55 +03:00
result = subprocess . run ( cmd )
if args . vm and result . returncode == 247 and args . exit_code != 247 :
2024-11-23 14:59:32 +03:00
print (
f ' Test { args . name } failed due to QEMU crash (error 247), ignoring ' ,
file = sys . stderr ,
)
2024-10-07 18:48:55 +03:00
exit ( 77 )
2024-11-23 00:51:45 +03:00
coredumps = process_coredumps ( args , journal_file )
2024-12-07 07:36:39 +03:00
sanitizer = False
if summary . environment . get ( ' SANITIZERS ' ) :
sanitizer = process_sanitizer_report ( args , journal_file )
2024-06-04 11:54:22 +03:00
if (
summary . environment . get ( ' COVERAGE ' , ' 0 ' ) == ' 1 '
and result . returncode in ( args . exit_code , 77 )
and not coredumps
2024-12-07 07:36:39 +03:00
and not sanitizer
2024-06-04 11:54:22 +03:00
) :
process_coverage ( args , summary , name , journal_file )
2024-11-23 00:51:45 +03:00
if keep_journal == ' 0 ' or (
2024-12-07 07:36:39 +03:00
keep_journal == ' fail '
and result . returncode in ( args . exit_code , 77 )
and not coredumps
and not sanitizer
2024-11-23 14:59:32 +03:00
) :
2024-06-24 17:20:11 +03:00
journal_file . unlink ( missing_ok = True )
2024-05-07 12:50:11 +03:00
2024-12-07 07:36:39 +03:00
if shell or ( result . returncode in ( args . exit_code , 77 ) and not coredumps and not sanitizer ) :
2024-08-02 16:46:41 +03:00
exit ( 0 if shell or result . returncode == args . exit_code else 77 )
2024-04-19 10:17:58 +03:00
2024-11-23 00:51:45 +03:00
ops = [ ]
if os . getenv ( ' GITHUB_ACTIONS ' ) :
id = os . environ [ ' GITHUB_RUN_ID ' ]
2024-06-04 11:54:22 +03:00
workflow = os . environ [ ' GITHUB_WORKFLOW ' ]
2024-11-23 00:51:45 +03:00
iteration = os . environ [ ' GITHUB_RUN_ATTEMPT ' ]
2024-06-04 11:54:22 +03:00
artifact = (
f ' ci- { workflow } - { id } - { iteration } - { summary . distribution } - { summary . release } -failed-test-journals '
)
2024-11-23 00:51:45 +03:00
ops + = [ f ' gh run download { id } --name { artifact } -D ci/ { artifact } ' ]
journal_file = Path ( f ' ci/ { artifact } /test/journal/ { name } .journal ' )
ops + = [ f ' journalctl --file { journal_file } --no-hostname -o short-monotonic -u { args . unit } -p info ' ]
print ( " Test failed, relevant logs can be viewed with: \n \n " f " { ( ' && ' . join ( ops ) ) } \n " , file = sys . stderr )
2024-05-07 12:50:11 +03:00
# 0 also means we failed so translate that to a non-zero exit code to mark the test as failed.
exit ( result . returncode or 1 )
2024-04-19 10:17:58 +03:00
if __name__ == ' __main__ ' :
main ( )