diff options
Diffstat (limited to 'bin/tests/system/conftest.py')
-rw-r--r-- | bin/tests/system/conftest.py | 639 |
1 files changed, 639 insertions, 0 deletions
diff --git a/bin/tests/system/conftest.py b/bin/tests/system/conftest.py new file mode 100644 index 0000000..50510a0 --- /dev/null +++ b/bin/tests/system/conftest.py @@ -0,0 +1,639 @@ +# Copyright (C) Internet Systems Consortium, Inc. ("ISC") +# +# SPDX-License-Identifier: MPL-2.0 +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, you can obtain one at https://mozilla.org/MPL/2.0/. +# +# See the COPYRIGHT file distributed with this work for additional +# information regarding copyright ownership. + +import logging +import os +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.fixture(scope="module") +def named_port(): + return int(os.environ.get("PORT", default=5300)) + + +@pytest.fixture(scope="module") +def named_tlsport(): + return int(os.environ.get("TLSPORT", default=8853)) + + +@pytest.fixture(scope="module") +def named_httpsport(): + return int(os.environ.get("HTTPSPORT", default=4443)) + + +@pytest.fixture(scope="module") +def control_port(): + return int(os.environ.get("CONTROLPORT", default=9953)) + + +if os.getenv("LEGACY_TEST_RUNNER", "0") != "0": + + @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 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. 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" + ) + + # 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), + } + + @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.") + + 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}") + + 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 + + # 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 |