diff options
Diffstat (limited to 'bin/tests/system/conftest.py')
-rw-r--r-- | bin/tests/system/conftest.py | 1189 |
1 files changed, 600 insertions, 589 deletions
diff --git a/bin/tests/system/conftest.py b/bin/tests/system/conftest.py index 50510a0..034b2c8 100644 --- a/bin/tests/system/conftest.py +++ b/bin/tests/system/conftest.py @@ -9,631 +9,642 @@ # See the COPYRIGHT file distributed with this work for additional # information regarding copyright ownership. +from functools import partial import logging import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import time +from typing import Any, Dict, List, Optional + import pytest -# ======================= LEGACY=COMPATIBLE FIXTURES ========================= -# The following fixtures are designed to work with both pytest system test -# runner and the legacy system test framework. -# -# FUTURE: Rewrite the individual port fixtures to re-use the `ports` fixture. +pytest.register_assert_rewrite("isctest") + + +# Silence warnings caused by passing a pytest fixture to another fixture. +# pylint: disable=redefined-outer-name + + +# ----------------- Older pytest / xdist compatibility ------------------- +# As of 2023-01-11, the minimal supported pytest / xdist versions are +# determined by what is available in EL8/EPEL8: +# - pytest 3.4.2 +# - pytest-xdist 1.24.1 +_pytest_ver = pytest.__version__.split(".") +_pytest_major_ver = int(_pytest_ver[0]) +if _pytest_major_ver < 7: + # pytest.Stash/pytest.StashKey mechanism has been added in 7.0.0 + # for older versions, use regular dictionary with string keys instead + FIXTURE_OK = "fixture_ok" # type: Any +else: + FIXTURE_OK = pytest.StashKey[bool]() # pylint: disable=no-member + +# ----------------------- Globals definition ----------------------------- + +LOG_FORMAT = "%(asctime)s %(levelname)7s:%(name)s %(message)s" +XDIST_WORKER = os.environ.get("PYTEST_XDIST_WORKER", "") +FILE_DIR = os.path.abspath(Path(__file__).parent) +ENV_RE = re.compile(b"([^=]+)=(.*)") +PORT_MIN = 5001 +PORT_MAX = 32767 +PORTS_PER_TEST = 20 +PRIORITY_TESTS = [ + # Tests that are scheduled first. Speeds up parallel execution. + "dupsigs/", + "rpz/", + "rpzrecurse/", + "serve-stale/", + "timeouts/", + "upforwd/", +] +PRIORITY_TESTS_RE = re.compile("|".join(PRIORITY_TESTS)) +CONFTEST_LOGGER = logging.getLogger("conftest") +SYSTEM_TEST_DIR_GIT_PATH = "bin/tests/system" +SYSTEM_TEST_NAME_RE = re.compile(f"{SYSTEM_TEST_DIR_GIT_PATH}" + r"/([^/]+)") +SYMLINK_REPLACEMENT_RE = re.compile(r"/tests(_.*)\.py") + +# ---------------------- Module initialization --------------------------- + + +def init_pytest_conftest_logger(conftest_logger): + """ + This initializes the conftest logger which is used for pytest setup + and configuration before tests are executed -- aka any logging in this + file that is _not_ module-specific. + """ + conftest_logger.setLevel(logging.DEBUG) + file_handler = logging.FileHandler("pytest.conftest.log.txt") + file_handler.setLevel(logging.DEBUG) + file_handler.setFormatter(logging.Formatter(LOG_FORMAT)) + conftest_logger.addHandler(file_handler) + + +init_pytest_conftest_logger(CONFTEST_LOGGER) + + +def avoid_duplicated_logs(): + """ + Remove direct root logger output to file descriptors. + This default is causing duplicates because all our messages go through + regular logging as well and are thus displayed twice. + """ + todel = [] + for handler in logging.root.handlers: + if handler.__class__ == logging.StreamHandler: + # Beware: As for pytest 7.2.2, LiveLogging and LogCapture + # handlers inherit from logging.StreamHandler + todel.append(handler) + for handler in todel: + logging.root.handlers.remove(handler) + + +def parse_env(env_bytes): + """Parse the POSIX env format into Python dictionary.""" + out = {} + for line in env_bytes.splitlines(): + match = ENV_RE.match(line) + if match: + # EL8+ workaround for https://access.redhat.com/solutions/6994985 + # FUTURE: can be removed when we no longer need to parse env vars + if match.groups()[0] in [b"which_declare", b"BASH_FUNC_which%%"]: + continue + out[match.groups()[0]] = match.groups()[1] + return out + + +def get_env_bytes(cmd): + try: + proc = subprocess.run( + [cmd], + shell=True, + check=True, + cwd=FILE_DIR, + stdout=subprocess.PIPE, + ) + except subprocess.CalledProcessError as exc: + CONFTEST_LOGGER.error("failed to get shell env: %s", exc) + raise exc + env_bytes = proc.stdout + return parse_env(env_bytes) + + +# Read common environment variables for running tests from conf.sh. +# FUTURE: Remove conf.sh entirely and define all variables in pytest only. +CONF_ENV = get_env_bytes(". ./conf.sh && env") +os.environb.update(CONF_ENV) +CONFTEST_LOGGER.debug("variables in env: %s", ", ".join([str(key) for key in CONF_ENV])) + +# --------------------------- pytest hooks ------------------------------- + + +def pytest_addoption(parser): + parser.addoption( + "--noclean", + action="store_true", + default=False, + help="don't remove the temporary test directories with artifacts", + ) + + +def pytest_configure(config): + # Ensure this hook only runs on the main pytest instance if xdist is + # used to spawn other workers. + if not XDIST_WORKER: + if config.pluginmanager.has_plugin("xdist") and config.option.numprocesses: + # system tests depend on module scope for setup & teardown + # enforce use "loadscope" scheduler or disable paralelism + try: + import xdist.scheduler.loadscope # pylint: disable=unused-import + except ImportError: + CONFTEST_LOGGER.debug( + "xdist is too old and does not have " + "scheduler.loadscope, disabling parallelism" + ) + config.option.dist = "no" + else: + config.option.dist = "loadscope" + + +def pytest_ignore_collect(path): + # System tests are executed in temporary directories inside + # bin/tests/system. These temporary directories contain all files + # needed for the system tests - including tests_*.py files. Make sure to + # ignore these during test collection phase. Otherwise, test artifacts + # from previous runs could mess with the runner. Also ignore the + # convenience symlinks to those test directories. In both of those + # cases, the system test name (directory) contains an underscore, which + # is otherwise and invalid character for a system test name. + match = SYSTEM_TEST_NAME_RE.search(str(path)) + if match is None: + CONFTEST_LOGGER.warning("unexpected test path: %s (ignored)", path) + return True + system_test_name = match.groups()[0] + return "_" in system_test_name + + +def pytest_collection_modifyitems(items): + """Schedule long-running tests first to get more benefit from parallelism.""" + priority = [] + other = [] + for item in items: + if PRIORITY_TESTS_RE.search(item.nodeid): + priority.append(item) + else: + other.append(item) + items[:] = priority + other + + +class NodeResult: + def __init__(self, report=None): + self.outcome = None + self.messages = [] + if report is not None: + self.update(report) + + def update(self, report): + if self.outcome is None or report.outcome != "passed": + self.outcome = report.outcome + if report.longreprtext: + self.messages.append(report.longreprtext) + + +@pytest.hookimpl(tryfirst=True, hookwrapper=True) +def pytest_runtest_makereport(item): + """Hook that is used to expose test results to session (for use in fixtures).""" + # execute all other hooks to obtain the report object + outcome = yield + report = outcome.get_result() + + # Set the test outcome in session, so we can access it from module-level + # fixture using nodeid. Note that this hook is called three times: for + # setup, call and teardown. We only care about the overall result so we + # merge the results together and preserve the information whether a test + # passed. + test_results = {} + try: + test_results = getattr(item.session, "test_results") + except AttributeError: + setattr(item.session, "test_results", test_results) + node_result = test_results.setdefault(item.nodeid, NodeResult()) + node_result.update(report) + + +# --------------------------- Fixtures ----------------------------------- + + +@pytest.fixture(scope="session") +def modules(): + """ + Sorted list of ALL modules. + + The list includes even test modules that are not tested in the current + session. It is used to determine port distribution. Using a complete + list of all possible test modules allows independent concurrent pytest + invocations. + """ + mods = [] + for dirpath, _dirs, files in os.walk(FILE_DIR): + for file in files: + if file.startswith("tests_") and file.endswith(".py"): + mod = f"{dirpath}/{file}" + if not pytest_ignore_collect(mod): + mods.append(mod) + return sorted(mods) + + +@pytest.fixture(scope="session") +def module_base_ports(modules): + """ + Dictionary containing assigned base port for every module. + + The port numbers are deterministically assigned before any testing + starts. This fixture MUST return the same value when called again + during the same test session. When running tests in parallel, this is + exactly what happens - every worker thread will call this fixture to + determine test ports. + """ + port_min = PORT_MIN + port_max = PORT_MAX - len(modules) * PORTS_PER_TEST + if port_max < port_min: + raise RuntimeError("not enough ports to assign unique port set to each module") + + # Rotate the base port value over time to detect possible test issues + # with using random ports. This introduces a very slight race condition + # risk. If this value changes between pytest invocation and spawning + # worker threads, multiple tests may have same port values assigned. If + # these tests are then executed simultaneously, the test results will + # be misleading. + base_port = int(time.time() // 3600) % (port_max - port_min) + port_min + + return {mod: base_port + i * PORTS_PER_TEST for i, mod in enumerate(modules)} @pytest.fixture(scope="module") -def named_port(): - return int(os.environ.get("PORT", default=5300)) +def base_port(request, module_base_ports): + """Start of the port range assigned to a particular test module.""" + port = module_base_ports[request.fspath] + return port @pytest.fixture(scope="module") -def named_tlsport(): - return int(os.environ.get("TLSPORT", default=8853)) +def ports(base_port): + """Dictionary containing port names and their assigned values.""" + return { + "PORT": base_port, + "TLSPORT": base_port + 1, + "HTTPPORT": base_port + 2, + "HTTPSPORT": base_port + 3, + "EXTRAPORT1": base_port + 4, + "EXTRAPORT2": base_port + 5, + "EXTRAPORT3": base_port + 6, + "EXTRAPORT4": base_port + 7, + "EXTRAPORT5": base_port + 8, + "EXTRAPORT6": base_port + 9, + "EXTRAPORT7": base_port + 10, + "EXTRAPORT8": base_port + 11, + "CONTROLPORT": base_port + 12, + } @pytest.fixture(scope="module") -def named_httpsport(): - return int(os.environ.get("HTTPSPORT", default=4443)) +def named_port(ports): + return ports["PORT"] @pytest.fixture(scope="module") -def control_port(): - return int(os.environ.get("CONTROLPORT", default=9953)) +def named_tlsport(ports): + return ports["TLSPORT"] -if os.getenv("LEGACY_TEST_RUNNER", "0") != "0": +@pytest.fixture(scope="module") +def named_httpsport(ports): + return ports["HTTPSPORT"] - @pytest.fixture - def logger(request): - """Logging facility specific to a particular test.""" - return logging.getLogger(request.node.name) -else: - # ======================= PYTEST SYSTEM TEST RUNNER ========================== - # From this point onward, any setting, fixtures or functions only apply to the - # new pytest runner. Ideally, these would be in a separate file. However, due - # to how pytest works and how it's used by the legacy runner, the best approach - # is to have everything in this file to avoid duplication and set the - # LEGACY_TEST_RUNNER if pytest is executed from the legacy framework. - # - # FUTURE: Once legacy runner is no longer supported, remove the env var and - # don't branch the code. - - from functools import partial - from pathlib import Path - import re - import shutil - import subprocess - import tempfile - import time - from typing import Any, Dict, List, Optional - - # Silence warnings caused by passing a pytest fixture to another fixture. - # pylint: disable=redefined-outer-name - - # ----------------- Older pytest / xdist compatibility ------------------- - # As of 2023-01-11, the minimal supported pytest / xdist versions are - # determined by what is available in EL8/EPEL8: - # - pytest 3.4.2 - # - pytest-xdist 1.24.1 - _pytest_ver = pytest.__version__.split(".") - _pytest_major_ver = int(_pytest_ver[0]) - if _pytest_major_ver < 7: - # pytest.Stash/pytest.StashKey mechanism has been added in 7.0.0 - # for older versions, use regular dictionary with string keys instead - FIXTURE_OK = "fixture_ok" # type: Any - else: - FIXTURE_OK = pytest.StashKey[bool]() # pylint: disable=no-member - - # ----------------------- Globals definition ----------------------------- - - LOG_FORMAT = "%(asctime)s %(levelname)7s:%(name)s %(message)s" - XDIST_WORKER = os.environ.get("PYTEST_XDIST_WORKER", "") - FILE_DIR = os.path.abspath(Path(__file__).parent) - ENV_RE = re.compile(b"([^=]+)=(.*)") - PORT_MIN = 5001 - PORT_MAX = 32767 - PORTS_PER_TEST = 20 - PRIORITY_TESTS = [ - # Tests that are scheduled first. Speeds up parallel execution. - "dupsigs/", - "rpz/", - "rpzrecurse/", - "serve-stale/", - "timeouts/", - "upforwd/", - ] - PRIORITY_TESTS_RE = re.compile("|".join(PRIORITY_TESTS)) - CONFTEST_LOGGER = logging.getLogger("conftest") - SYSTEM_TEST_DIR_GIT_PATH = "bin/tests/system" - SYSTEM_TEST_NAME_RE = re.compile(f"{SYSTEM_TEST_DIR_GIT_PATH}" + r"/([^/]+)") - SYMLINK_REPLACEMENT_RE = re.compile(r"/tests(_sh(?=_))?(.*)\.py") - - # ---------------------- Module initialization --------------------------- - - def init_pytest_conftest_logger(conftest_logger): - """ - This initializes the conftest logger which is used for pytest setup - and configuration before tests are executed -- aka any logging in this - file that is _not_ module-specific. - """ - conftest_logger.setLevel(logging.DEBUG) - file_handler = logging.FileHandler("pytest.conftest.log.txt") - file_handler.setLevel(logging.DEBUG) - file_handler.setFormatter(logging.Formatter(LOG_FORMAT)) - conftest_logger.addHandler(file_handler) - - init_pytest_conftest_logger(CONFTEST_LOGGER) - - def avoid_duplicated_logs(): - """ - Remove direct root logger output to file descriptors. - This default is causing duplicates because all our messages go through - regular logging as well and are thus displayed twice. - """ - todel = [] - for handler in logging.root.handlers: - if handler.__class__ == logging.StreamHandler: - # Beware: As for pytest 7.2.2, LiveLogging and LogCapture - # handlers inherit from logging.StreamHandler - todel.append(handler) - for handler in todel: - logging.root.handlers.remove(handler) - - def parse_env(env_bytes): - """Parse the POSIX env format into Python dictionary.""" - out = {} - for line in env_bytes.splitlines(): - match = ENV_RE.match(line) - if match: - # EL8+ workaround for https://access.redhat.com/solutions/6994985 - # FUTURE: can be removed when we no longer need to parse env vars - if match.groups()[0] in [b"which_declare", b"BASH_FUNC_which%%"]: - continue - out[match.groups()[0]] = match.groups()[1] - return out - - def get_env_bytes(cmd): - try: - proc = subprocess.run( - [cmd], - shell=True, - check=True, - cwd=FILE_DIR, - stdout=subprocess.PIPE, - ) - except subprocess.CalledProcessError as exc: - CONFTEST_LOGGER.error("failed to get shell env: %s", exc) - raise exc - env_bytes = proc.stdout - return parse_env(env_bytes) - - # Read common environment variables for running tests from conf.sh. - # FUTURE: Remove conf.sh entirely and define all variables in pytest only. - CONF_ENV = get_env_bytes(". ./conf.sh && env") - os.environb.update(CONF_ENV) - CONFTEST_LOGGER.debug( - "variables in env: %s", ", ".join([str(key) for key in CONF_ENV]) - ) +@pytest.fixture(scope="module") +def control_port(ports): + return ports["CONTROLPORT"] - # --------------------------- pytest hooks ------------------------------- - def pytest_addoption(parser): - parser.addoption( - "--noclean", - action="store_true", - default=False, - help="don't remove the temporary test directories with artifacts", - ) +@pytest.fixture(scope="module") +def env(ports): + """Dictionary containing environment variables for the test.""" + env = os.environ.copy() + for portname, portnum in ports.items(): + env[portname] = str(portnum) + env["builddir"] = f"{env['TOP_BUILDDIR']}/{SYSTEM_TEST_DIR_GIT_PATH}" + env["srcdir"] = f"{env['TOP_SRCDIR']}/{SYSTEM_TEST_DIR_GIT_PATH}" + return env - def pytest_configure(config): - # Ensure this hook only runs on the main pytest instance if xdist is - # used to spawn other workers. - if not XDIST_WORKER: - if config.pluginmanager.has_plugin("xdist") and config.option.numprocesses: - # system tests depend on module scope for setup & teardown - # enforce use "loadscope" scheduler or disable paralelism - try: - import xdist.scheduler.loadscope # pylint: disable=unused-import - except ImportError: - CONFTEST_LOGGER.debug( - "xdist is too old and does not have " - "scheduler.loadscope, disabling parallelism" - ) - config.option.dist = "no" - else: - config.option.dist = "loadscope" - - def pytest_ignore_collect(path): - # System tests are executed in temporary directories inside - # bin/tests/system. These temporary directories contain all files - # needed for the system tests - including tests_*.py files. Make sure to - # ignore these during test collection phase. Otherwise, test artifacts - # from previous runs could mess with the runner. Also ignore the - # convenience symlinks to those test directories. In both of those - # cases, the system test name (directory) contains an underscore, which - # is otherwise and invalid character for a system test name. - match = SYSTEM_TEST_NAME_RE.search(str(path)) - if match is None: - CONFTEST_LOGGER.warning("unexpected test path: %s (ignored)", path) - return True - system_test_name = match.groups()[0] - return "_" in system_test_name - - def pytest_collection_modifyitems(items): - """Schedule long-running tests first to get more benefit from parallelism.""" - priority = [] - other = [] - for item in items: - if PRIORITY_TESTS_RE.search(item.nodeid): - priority.append(item) - else: - other.append(item) - items[:] = priority + other - - class NodeResult: - def __init__(self, report=None): - self.outcome = None - self.messages = [] - if report is not None: - self.update(report) - - def update(self, report): - if self.outcome is None or report.outcome != "passed": - self.outcome = report.outcome - if report.longreprtext: - self.messages.append(report.longreprtext) - - @pytest.hookimpl(tryfirst=True, hookwrapper=True) - def pytest_runtest_makereport(item): - """Hook that is used to expose test results to session (for use in fixtures).""" - # execute all other hooks to obtain the report object - outcome = yield - report = outcome.get_result() - - # Set the test outcome in session, so we can access it from module-level - # fixture using nodeid. Note that this hook is called three times: for - # setup, call and teardown. We only care about the overall result so we - # merge the results together and preserve the information whether a test - # passed. - test_results = {} + +@pytest.fixture(scope="module") +def system_test_name(request): + """Name of the system test directory.""" + path = Path(request.fspath) + return path.parent.name + + +@pytest.fixture(scope="module") +def mlogger(system_test_name): + """Logging facility specific to this test module.""" + avoid_duplicated_logs() + return logging.getLogger(system_test_name) + + +@pytest.fixture +def logger(request, system_test_name): + """Logging facility specific to a particular test.""" + return logging.getLogger(f"{system_test_name}.{request.node.name}") + + +@pytest.fixture(scope="module") +def system_test_dir( + request, env, system_test_name, mlogger +): # pylint: disable=too-many-statements,too-many-locals + """ + Temporary directory for executing the test. + + This fixture is responsible for creating (and potentially removing) a + copy of the system test directory which is used as a temporary + directory for the test execution. + + FUTURE: This removes the need to have clean.sh scripts. + """ + + def get_test_result(): + """Aggregate test results from all individual tests from this module + into a single result: failed > skipped > passed.""" try: - test_results = getattr(item.session, "test_results") + all_test_results = request.session.test_results except AttributeError: - setattr(item.session, "test_results", test_results) - node_result = test_results.setdefault(item.nodeid, NodeResult()) - node_result.update(report) - - # --------------------------- Fixtures ----------------------------------- - - @pytest.fixture(scope="session") - def modules(): - """Sorted list of all modules. Used to determine port distribution.""" - mods = [] - for dirpath, _dirs, files in os.walk(os.getcwd()): - for file in files: - if file.startswith("tests_") and file.endswith(".py"): - mod = f"{dirpath}/{file}" - mods.append(mod) - return sorted(mods) - - @pytest.fixture(scope="session") - def module_base_ports(modules): - """ - Dictionary containing assigned base port for every module. - - Note that this is a session-wide fixture. The port numbers are - deterministically assigned before any testing starts. This fixture MUST - return the same value when called again during the same test session. - When running tests in parallel, this is exactly what happens - every - worker thread will call this fixture to determine test ports. - """ - port_min = PORT_MIN - port_max = PORT_MAX - len(modules) * PORTS_PER_TEST - if port_max < port_min: - raise RuntimeError( - "not enough ports to assign unique port set to each module" + # This may happen if pytest execution is interrupted and + # pytest_runtest_makereport() is never called. + mlogger.debug("can't obtain test results, test run was interrupted") + return "error" + test_results = { + node.nodeid: all_test_results[node.nodeid] + for node in request.node.collect() + if node.nodeid in all_test_results + } + assert len(test_results) + messages = [] + for node, result in test_results.items(): + mlogger.debug("%s %s", result.outcome.upper(), node) + messages.extend(result.messages) + for message in messages: + mlogger.debug("\n" + message) + failed = any(res.outcome == "failed" for res in test_results.values()) + skipped = any(res.outcome == "skipped" for res in test_results.values()) + if failed: + return "failed" + if skipped: + return "skipped" + assert all(res.outcome == "passed" for res in test_results.values()) + return "passed" + + def unlink(path): + try: + path.unlink() # missing_ok=True isn't available on Python 3.6 + except FileNotFoundError: + pass + + # Create a temporary directory with a copy of the original system test dir contents + system_test_root = Path(f"{env['TOP_BUILDDIR']}/{SYSTEM_TEST_DIR_GIT_PATH}") + testdir = Path( + tempfile.mkdtemp(prefix=f"{system_test_name}_tmp_", dir=system_test_root) + ) + shutil.rmtree(testdir) + shutil.copytree(system_test_root / system_test_name, testdir) + + # Create a convenience symlink with a stable and predictable name + module_name = SYMLINK_REPLACEMENT_RE.sub(r"\1", request.node.name) + symlink_dst = system_test_root / module_name + unlink(symlink_dst) + symlink_dst.symlink_to(os.path.relpath(testdir, start=system_test_root)) + + # Configure logger to write to a file inside the temporary test directory + mlogger.handlers.clear() + mlogger.setLevel(logging.DEBUG) + handler = logging.FileHandler(testdir / "pytest.log.txt", mode="w") + formatter = logging.Formatter(LOG_FORMAT) + handler.setFormatter(formatter) + mlogger.addHandler(handler) + + # System tests are meant to be executed from their directory - switch to it. + old_cwd = os.getcwd() + os.chdir(testdir) + mlogger.debug("switching to tmpdir: %s", testdir) + try: + yield testdir # other fixtures / tests will execute here + finally: + os.chdir(old_cwd) + mlogger.debug("changed workdir to: %s", old_cwd) + + result = get_test_result() + + # Clean temporary dir unless it should be kept + keep = False + if request.config.getoption("--noclean"): + mlogger.debug( + "--noclean requested, keeping temporary directory %s", testdir ) + keep = True + elif result == "failed": + mlogger.debug( + "test failure detected, keeping temporary directory %s", testdir + ) + keep = True + elif not request.node.stash[FIXTURE_OK]: + mlogger.debug( + "test setup/teardown issue detected, keeping temporary directory %s", + testdir, + ) + keep = True - # Rotate the base port value over time to detect possible test issues - # with using random ports. This introduces a very slight race condition - # risk. If this value changes between pytest invocation and spawning - # worker threads, multiple tests may have same port values assigned. If - # these tests are then executed simultaneously, the test results will - # be misleading. - base_port = int(time.time() // 3600) % (port_max - port_min) + port_min - - return {mod: base_port + i * PORTS_PER_TEST for i, mod in enumerate(modules)} - - @pytest.fixture(scope="module") - def base_port(request, module_base_ports): - """Start of the port range assigned to a particular test module.""" - port = module_base_ports[request.fspath] - return port - - @pytest.fixture(scope="module") - def ports(base_port): - """Dictionary containing port names and their assigned values.""" - return { - "PORT": str(base_port), - "TLSPORT": str(base_port + 1), - "HTTPPORT": str(base_port + 2), - "HTTPSPORT": str(base_port + 3), - "EXTRAPORT1": str(base_port + 4), - "EXTRAPORT2": str(base_port + 5), - "EXTRAPORT3": str(base_port + 6), - "EXTRAPORT4": str(base_port + 7), - "EXTRAPORT5": str(base_port + 8), - "EXTRAPORT6": str(base_port + 9), - "EXTRAPORT7": str(base_port + 10), - "EXTRAPORT8": str(base_port + 11), - "CONTROLPORT": str(base_port + 12), - } + if keep: + mlogger.info( + "test artifacts in: %s", symlink_dst.relative_to(system_test_root) + ) + else: + mlogger.debug("deleting temporary directory") + handler.flush() + handler.close() + shutil.rmtree(testdir) + unlink(symlink_dst) + + +def _run_script( # pylint: disable=too-many-arguments + env, + mlogger, + system_test_dir: Path, + interpreter: str, + script: str, + args: Optional[List[str]] = None, +): + """Helper function for the shell / perl script invocations (through fixtures below).""" + if args is None: + args = [] + path = Path(script) + if not path.is_absolute(): + # make sure relative paths are always relative to system_dir + path = system_test_dir.parent / path + script = str(path) + cwd = os.getcwd() + if not path.exists(): + raise FileNotFoundError(f"script {script} not found in {cwd}") + mlogger.debug("running script: %s %s %s", interpreter, script, " ".join(args)) + mlogger.debug(" workdir: %s", cwd) + returncode = 1 + + cmd = [interpreter, script] + args + with subprocess.Popen( + cmd, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + universal_newlines=True, + errors="backslashreplace", + ) as proc: + if proc.stdout: + for line in proc.stdout: + mlogger.info(" %s", line.rstrip("\n")) + proc.communicate() + returncode = proc.returncode + if returncode: + raise subprocess.CalledProcessError(returncode, cmd) + mlogger.debug(" exited with %d", returncode) - @pytest.fixture(scope="module") - def env(ports): - """Dictionary containing environment variables for the test.""" - env = os.environ.copy() - env.update(ports) - env["builddir"] = f"{env['TOP_BUILDDIR']}/{SYSTEM_TEST_DIR_GIT_PATH}" - env["srcdir"] = f"{env['TOP_SRCDIR']}/{SYSTEM_TEST_DIR_GIT_PATH}" - return env - - @pytest.fixture(scope="module") - def system_test_name(request): - """Name of the system test directory.""" - path = Path(request.fspath) - return path.parent.name - - @pytest.fixture(scope="module") - def mlogger(system_test_name): - """Logging facility specific to this test module.""" - avoid_duplicated_logs() - return logging.getLogger(system_test_name) - - @pytest.fixture - def logger(request, system_test_name): - """Logging facility specific to a particular test.""" - return logging.getLogger(f"{system_test_name}.{request.node.name}") - - @pytest.fixture(scope="module") - def system_test_dir( - request, env, system_test_name, mlogger - ): # pylint: disable=too-many-statements,too-many-locals - """ - Temporary directory for executing the test. - - This fixture is responsible for creating (and potentially removing) a - copy of the system test directory which is used as a temporary - directory for the test execution. - - FUTURE: This removes the need to have clean.sh scripts. - """ - - def get_test_result(): - """Aggregate test results from all individual tests from this module - into a single result: failed > skipped > passed.""" - try: - all_test_results = request.session.test_results - except AttributeError: - # This may happen if pytest execution is interrupted and - # pytest_runtest_makereport() is never called. - mlogger.debug("can't obtain test results, test run was interrupted") - return "error" - test_results = { - node.nodeid: all_test_results[node.nodeid] - for node in request.node.collect() - if node.nodeid in all_test_results - } - assert len(test_results) - messages = [] - for node, result in test_results.items(): - mlogger.debug("%s %s", result.outcome.upper(), node) - messages.extend(result.messages) - for message in messages: - mlogger.debug("\n" + message) - failed = any(res.outcome == "failed" for res in test_results.values()) - skipped = any(res.outcome == "skipped" for res in test_results.values()) - if failed: - return "failed" - if skipped: - return "skipped" - assert all(res.outcome == "passed" for res in test_results.values()) - return "passed" - - def unlink(path): - try: - path.unlink() # missing_ok=True isn't available on Python 3.6 - except FileNotFoundError: - pass - - # Create a temporary directory with a copy of the original system test dir contents - system_test_root = Path(f"{env['TOP_BUILDDIR']}/{SYSTEM_TEST_DIR_GIT_PATH}") - testdir = Path( - tempfile.mkdtemp(prefix=f"{system_test_name}_tmp_", dir=system_test_root) - ) - shutil.rmtree(testdir) - shutil.copytree(system_test_root / system_test_name, testdir) - - # Create a convenience symlink with a stable and predictable name - module_name = SYMLINK_REPLACEMENT_RE.sub(r"\2", request.node.name) - symlink_dst = system_test_root / module_name - unlink(symlink_dst) - symlink_dst.symlink_to(os.path.relpath(testdir, start=system_test_root)) - - # Configure logger to write to a file inside the temporary test directory - mlogger.handlers.clear() - mlogger.setLevel(logging.DEBUG) - handler = logging.FileHandler(testdir / "pytest.log.txt", mode="w") - formatter = logging.Formatter(LOG_FORMAT) - handler.setFormatter(formatter) - mlogger.addHandler(handler) - - # System tests are meant to be executed from their directory - switch to it. - old_cwd = os.getcwd() - os.chdir(testdir) - mlogger.debug("switching to tmpdir: %s", testdir) - try: - yield testdir # other fixtures / tests will execute here - finally: - os.chdir(old_cwd) - mlogger.debug("changed workdir to: %s", old_cwd) - - result = get_test_result() - - # Clean temporary dir unless it should be kept - keep = False - if request.config.getoption("--noclean"): - mlogger.debug( - "--noclean requested, keeping temporary directory %s", testdir - ) - keep = True - elif result == "failed": - mlogger.debug( - "test failure detected, keeping temporary directory %s", testdir - ) - keep = True - elif not request.node.stash[FIXTURE_OK]: - mlogger.debug( - "test setup/teardown issue detected, keeping temporary directory %s", - testdir, - ) - keep = True - if keep: - mlogger.info( - "test artifacts in: %s", symlink_dst.relative_to(system_test_root) - ) - else: - mlogger.debug("deleting temporary directory") - handler.flush() - handler.close() - shutil.rmtree(testdir) - unlink(symlink_dst) - - def _run_script( # pylint: disable=too-many-arguments - env, - mlogger, - system_test_dir: Path, - interpreter: str, - script: str, - args: Optional[List[str]] = None, - ): - """Helper function for the shell / perl script invocations (through fixtures below).""" - if args is None: - args = [] - path = Path(script) - if not path.is_absolute(): - # make sure relative paths are always relative to system_dir - path = system_test_dir.parent / path - script = str(path) - cwd = os.getcwd() - if not path.exists(): - raise FileNotFoundError(f"script {script} not found in {cwd}") - mlogger.debug("running script: %s %s %s", interpreter, script, " ".join(args)) - mlogger.debug(" workdir: %s", cwd) - returncode = 1 - - cmd = [interpreter, script] + args - with subprocess.Popen( - cmd, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - bufsize=1, - universal_newlines=True, - errors="backslashreplace", - ) as proc: - if proc.stdout: - for line in proc.stdout: - mlogger.info(" %s", line.rstrip("\n")) - proc.communicate() - returncode = proc.returncode - if returncode: - raise subprocess.CalledProcessError(returncode, cmd) - mlogger.debug(" exited with %d", returncode) - - @pytest.fixture(scope="module") - def shell(env, system_test_dir, mlogger): - """Function to call a shell script with arguments.""" - return partial(_run_script, env, mlogger, system_test_dir, env["SHELL"]) - - @pytest.fixture(scope="module") - def perl(env, system_test_dir, mlogger): - """Function to call a perl script with arguments.""" - return partial(_run_script, env, mlogger, system_test_dir, env["PERL"]) - - @pytest.fixture(scope="module") - def run_tests_sh(system_test_dir, shell): - """Utility function to execute tests.sh as a python test.""" - - def run_tests(): - shell(f"{system_test_dir}/tests.sh") - - return run_tests - - @pytest.fixture(scope="module", autouse=True) - def system_test( # pylint: disable=too-many-arguments,too-many-statements - request, - env: Dict[str, str], - mlogger, - system_test_dir, - shell, - perl, - ): - """ - Driver of the test setup/teardown process. Used automatically for every test module. - - This is the most important one-fixture-to-rule-them-all. Note the - autouse=True which causes this fixture to be loaded by every test - module without the need to explicitly specify it. - - When this fixture is used, it utilizes other fixtures, such as - system_test_dir, which handles the creation of the temporary test - directory. - - Afterwards, it checks the test environment and takes care of starting - the servers. When everything is ready, that's when the actual tests are - executed. Once that is done, this fixture stops the servers and checks - for any artifacts indicating an issue (e.g. coredumps). - - Finally, when this fixture reaches an end (or encounters an exception, - which may be caused by fail/skip invocations), any fixtures which is - used by this one are finalized - e.g. system_test_dir performs final - checks and cleans up the temporary test directory. - """ - - def check_net_interfaces(): - try: - perl("testsock.pl", ["-p", env["PORT"]]) - except subprocess.CalledProcessError as exc: - mlogger.error("testsock.pl: exited with code %d", exc.returncode) - pytest.skip("Network interface aliases not set up.") +@pytest.fixture(scope="module") +def shell(env, system_test_dir, mlogger): + """Function to call a shell script with arguments.""" + return partial(_run_script, env, mlogger, system_test_dir, env["SHELL"]) - def check_prerequisites(): - try: - shell(f"{system_test_dir}/prereq.sh") - except FileNotFoundError: - pass # prereq.sh is optional - except subprocess.CalledProcessError: - pytest.skip("Prerequisites missing.") - def setup_test(): - try: - shell(f"{system_test_dir}/setup.sh") - except FileNotFoundError: - pass # setup.sh is optional - except subprocess.CalledProcessError as exc: - mlogger.error("Failed to run test setup") - pytest.fail(f"setup.sh exited with {exc.returncode}") - - def start_servers(): - try: - perl("start.pl", ["--port", env["PORT"], system_test_dir.name]) - except subprocess.CalledProcessError as exc: - mlogger.error("Failed to start servers") - pytest.fail(f"start.pl exited with {exc.returncode}") +@pytest.fixture(scope="module") +def perl(env, system_test_dir, mlogger): + """Function to call a perl script with arguments.""" + return partial(_run_script, env, mlogger, system_test_dir, env["PERL"]) - def stop_servers(): - try: - perl("stop.pl", [system_test_dir.name]) - except subprocess.CalledProcessError as exc: - mlogger.error("Failed to stop servers") - get_core_dumps() - pytest.fail(f"stop.pl exited with {exc.returncode}") - def get_core_dumps(): - try: - shell("get_core_dumps.sh", [system_test_dir.name]) - except subprocess.CalledProcessError as exc: - mlogger.error("Found core dumps or sanitizer reports") - pytest.fail(f"get_core_dumps.sh exited with {exc.returncode}") - - os.environ.update(env) # Ensure pytests have the same env vars as shell tests. - mlogger.info(f"test started: {request.node.name}") - port = int(env["PORT"]) - mlogger.info("using port range: <%d, %d>", port, port + PORTS_PER_TEST - 1) - - if not hasattr(request.node, "stash"): # compatibility with pytest<7.0.0 - request.node.stash = {} # use regular dict instead of pytest.Stash - request.node.stash[FIXTURE_OK] = True +@pytest.fixture(scope="module") +def run_tests_sh(system_test_dir, shell): + """Utility function to execute tests.sh as a python test.""" + + def run_tests(): + shell(f"{system_test_dir}/tests.sh") + + return run_tests + + +@pytest.fixture(scope="module", autouse=True) +def system_test( # pylint: disable=too-many-arguments,too-many-statements + request, + env: Dict[str, str], + mlogger, + system_test_dir, + shell, + perl, +): + """ + Driver of the test setup/teardown process. Used automatically for every test module. + + This is the most important one-fixture-to-rule-them-all. Note the + autouse=True which causes this fixture to be loaded by every test + module without the need to explicitly specify it. + + When this fixture is used, it utilizes other fixtures, such as + system_test_dir, which handles the creation of the temporary test + directory. + + Afterwards, it checks the test environment and takes care of starting + the servers. When everything is ready, that's when the actual tests are + executed. Once that is done, this fixture stops the servers and checks + for any artifacts indicating an issue (e.g. coredumps). + + Finally, when this fixture reaches an end (or encounters an exception, + which may be caused by fail/skip invocations), any fixtures which is + used by this one are finalized - e.g. system_test_dir performs final + checks and cleans up the temporary test directory. + """ + + def check_net_interfaces(): + try: + perl("testsock.pl", ["-p", env["PORT"]]) + except subprocess.CalledProcessError as exc: + mlogger.error("testsock.pl: exited with code %d", exc.returncode) + pytest.skip("Network interface aliases not set up.") - # Perform checks which may skip this test. - check_net_interfaces() - check_prerequisites() + def check_prerequisites(): + try: + shell(f"{system_test_dir}/prereq.sh") + except FileNotFoundError: + pass # prereq.sh is optional + except subprocess.CalledProcessError: + pytest.skip("Prerequisites missing.") - # Store the fact that this fixture hasn't successfully finished yet. - # This is checked before temporary directory teardown to decide whether - # it's okay to remove the directory. - request.node.stash[FIXTURE_OK] = False + def setup_test(): + try: + shell(f"{system_test_dir}/setup.sh") + except FileNotFoundError: + pass # setup.sh is optional + except subprocess.CalledProcessError as exc: + mlogger.error("Failed to run test setup") + pytest.fail(f"setup.sh exited with {exc.returncode}") + + def start_servers(): + try: + perl("start.pl", ["--port", env["PORT"], system_test_dir.name]) + except subprocess.CalledProcessError as exc: + mlogger.error("Failed to start servers") + pytest.fail(f"start.pl exited with {exc.returncode}") - setup_test() + def stop_servers(): try: - start_servers() - mlogger.debug("executing test(s)") - yield - finally: - mlogger.debug("test(s) finished") - stop_servers() + perl("stop.pl", [system_test_dir.name]) + except subprocess.CalledProcessError as exc: + mlogger.error("Failed to stop servers") get_core_dumps() - request.node.stash[FIXTURE_OK] = True + pytest.fail(f"stop.pl exited with {exc.returncode}") + + def get_core_dumps(): + try: + shell("get_core_dumps.sh", [system_test_dir.name]) + except subprocess.CalledProcessError as exc: + mlogger.error("Found core dumps or sanitizer reports") + pytest.fail(f"get_core_dumps.sh exited with {exc.returncode}") + + os.environ.update(env) # Ensure pytests have the same env vars as shell tests. + mlogger.info(f"test started: {request.node.name}") + port = int(env["PORT"]) + mlogger.info("using port range: <%d, %d>", port, port + PORTS_PER_TEST - 1) + + if not hasattr(request.node, "stash"): # compatibility with pytest<7.0.0 + request.node.stash = {} # use regular dict instead of pytest.Stash + request.node.stash[FIXTURE_OK] = True + + # Perform checks which may skip this test. + check_net_interfaces() + check_prerequisites() + + # Store the fact that this fixture hasn't successfully finished yet. + # This is checked before temporary directory teardown to decide whether + # it's okay to remove the directory. + request.node.stash[FIXTURE_OK] = False + + setup_test() + try: + start_servers() + mlogger.debug("executing test(s)") + yield + finally: + mlogger.debug("test(s) finished") + stop_servers() + get_core_dumps() + request.node.stash[FIXTURE_OK] = True |