frr/tests/topotests/munet/testing/fixtures.py
Daniel Baumann 3124f89aed
Adding upstream version 10.1.1.
Signed-off-by: Daniel Baumann <daniel@debian.org>
2025-02-05 10:03:58 +01:00

447 lines
14 KiB
Python

# -*- coding: utf-8 eval: (blacken-mode 1) -*-
# SPDX-License-Identifier: GPL-2.0-or-later
#
# April 22 2022, Christian Hopps <chopps@gmail.com>
#
# Copyright (c) 2022, LabN Consulting, L.L.C
#
"""A module that implements pytest fixtures.
To use in your project, in your conftest.py add:
from munet.testing.fixtures import *
"""
import contextlib
import logging
import os
from pathlib import Path
from typing import Union
import pytest
import pytest_asyncio
from ..base import BaseMunet
from ..base import Bridge
from ..base import get_event_loop
from ..cleanup import cleanup_current
from ..native import L3NodeMixin
from ..parser import async_build_topology
from ..parser import get_config
from .util import async_pause_test
from .util import pause_test
@contextlib.asynccontextmanager
async def achdir(ndir: Union[str, Path], desc=""):
odir = os.getcwd()
os.chdir(ndir)
if desc:
logging.debug("%s: chdir from %s to %s", desc, odir, ndir)
try:
yield
finally:
if desc:
logging.debug("%s: chdir back from %s to %s", desc, ndir, odir)
os.chdir(odir)
@contextlib.contextmanager
def chdir(ndir: Union[str, Path], desc=""):
odir = os.getcwd()
os.chdir(ndir)
if desc:
logging.debug("%s: chdir from %s to %s", desc, odir, ndir)
try:
yield
finally:
if desc:
logging.debug("%s: chdir back from %s to %s", desc, ndir, odir)
os.chdir(odir)
def get_test_logdir(nodeid=None, module=False):
"""Get log directory relative pathname."""
xdist_worker = os.getenv("PYTEST_XDIST_WORKER", "")
mode = os.getenv("PYTEST_XDIST_MODE", "no")
# nodeid: all_protocol_startup/test_all_protocol_startup.py::test_router_running
# may be missing "::testname" if module is True
if not nodeid:
nodeid = os.environ["PYTEST_CURRENT_TEST"].split(" ")[0]
cur_test = nodeid.replace("[", "_").replace("]", "_")
if module:
idx = cur_test.rfind("::")
path = cur_test if idx == -1 else cur_test[:idx]
testname = ""
else:
path, testname = cur_test.split("::")
testname = testname.replace("/", ".")
path = path[:-3].replace("/", ".")
# We use different logdir paths based on how xdist is running.
if mode == "each":
if module:
return os.path.join(path, "worker-logs", xdist_worker)
return os.path.join(path, testname, xdist_worker)
assert mode in ("no", "load", "loadfile", "loadscope"), f"Unknown dist mode {mode}"
return path if module else os.path.join(path, testname)
def _push_log_handler(desc, logpath):
logpath = os.path.abspath(logpath)
logging.debug("conftest: adding %s logging at %s", desc, logpath)
root_logger = logging.getLogger()
handler = logging.FileHandler(logpath, mode="w")
fmt = logging.Formatter("%(asctime)s %(levelname)5s: %(name)s: %(message)s")
handler.setFormatter(fmt)
root_logger.addHandler(handler)
return handler
def _pop_log_handler(handler):
root_logger = logging.getLogger()
logging.debug("conftest: removing logging handler %s", handler)
root_logger.removeHandler(handler)
@contextlib.contextmanager
def log_handler(desc, logpath):
handler = _push_log_handler(desc, logpath)
try:
yield
finally:
_pop_log_handler(handler)
# =================
# Sessions Fixtures
# =================
@pytest.fixture(autouse=True, scope="session")
def session_autouse():
if "PYTEST_TOPOTEST_WORKER" not in os.environ:
is_worker = False
elif not os.environ["PYTEST_TOPOTEST_WORKER"]:
is_worker = False
else:
is_worker = True
# We dont want to kill all munet and we don't have the rundir here yet
# This was more useful back when we used to leave processes around a lot
# more.
# if not is_worker:
# # This is unfriendly to multi-instance
# cleanup_previous()
# We never pop as we want to keep logging
_push_log_handler("session", "/tmp/unet-test/pytest-session.log")
yield
if not is_worker:
cleanup_current()
# ===============
# Module Fixtures
# ===============
@pytest.fixture(autouse=True, scope="module")
def module_autouse(request):
root_path = os.environ.get("MUNET_RUNDIR", "/tmp/unet-test")
logpath = get_test_logdir(request.node.nodeid, True)
logpath = os.path.join(root_path, logpath, "pytest-exec.log")
with log_handler("module", logpath):
sdir = os.path.dirname(os.path.realpath(request.fspath))
with chdir(sdir, "module autouse fixture"):
yield
if BaseMunet.g_unet:
raise Exception("Base Munet was not cleaned up/deleted")
@pytest.fixture(scope="session")
def event_loop():
"""Create an instance of the default event loop for the session."""
loop = get_event_loop()
try:
logging.info("event_loop_fixture: yielding with new event loop watcher")
yield loop
finally:
loop.close()
@pytest.fixture(scope="module")
def rundir_module():
root_path = os.environ.get("MUNET_RUNDIR", "/tmp/unet-test")
d = os.path.join(root_path, get_test_logdir(module=True))
logging.debug("conftest: test module rundir %s", d)
return d
async def _unet_impl(
_rundir, _pytestconfig, unshare=None, top_level_pidns=None, param=None
):
try:
# Default is not to unshare inline if not specified otherwise
unshare_default = False
pidns_default = True
if isinstance(param, (tuple, list)):
pidns_default = bool(param[2]) if len(param) > 2 else True
unshare_default = bool(param[1]) if len(param) > 1 else False
param = str(param[0])
elif isinstance(param, bool):
unshare_default = param
param = None
if unshare is None:
unshare = unshare_default
if top_level_pidns is None:
top_level_pidns = pidns_default
logging.info("unet fixture: basename=%s unshare_inline=%s", param, unshare)
_unet = await async_build_topology(
config=get_config(basename=param) if param else None,
rundir=_rundir,
unshare_inline=unshare,
top_level_pidns=top_level_pidns,
pytestconfig=_pytestconfig,
)
except Exception as error:
logging.debug(
"unet fixture: unet build failed: %s\nparam: %s",
error,
param,
exc_info=True,
)
pytest.fail(f"unet fixture: unet build failed: {error}")
try:
tasks = await _unet.run()
except Exception as error:
logging.debug("unet fixture: unet run failed: %s", error, exc_info=True)
await _unet.async_delete()
pytest.fail(f"unet fixture: unet run failed: {error}")
logging.debug("unet fixture: containers running")
# Pytest is supposed to always return even if exceptions
try:
yield _unet
except Exception as error:
logging.error("unet fixture: yield unet unexpected exception: %s", error)
logging.debug("unet fixture: module done, deleting unet")
await _unet.async_delete()
# No one ever awaits these so cancel them
logging.debug("unet fixture: cleanup")
for task in tasks:
task.cancel()
# Reset the class variables so auto number is predictable
logging.debug("unet fixture: resetting ords to 1")
L3NodeMixin.next_ord = 1
Bridge.next_ord = 1
@pytest.fixture(scope="module")
async def unet(request, rundir_module, pytestconfig): # pylint: disable=W0621
"""A unet creating fixutre.
The request param is either the basename of the config file or a tuple of the form:
(basename, unshare, top_level_pidns), with the second and third elements boolean and
optional, defaulting to False, True.
"""
param = request.param if hasattr(request, "param") else None
sdir = os.path.dirname(os.path.realpath(request.fspath))
async with achdir(sdir, "unet fixture"):
async for x in _unet_impl(rundir_module, pytestconfig, param=param):
yield x
@pytest.fixture(scope="module")
async def unet_share(request, rundir_module, pytestconfig): # pylint: disable=W0621
"""A unet creating fixutre.
This share variant keeps munet from unsharing the process to a new namespace so that
root level commands and actions are execute on the host, normally they are executed
in the munet namespace which allowing things like scapy inline in tests to work.
The request param is either the basename of the config file or a tuple of the form:
(basename, top_level_pidns), the second value is a boolean.
"""
param = request.param if hasattr(request, "param") else None
if isinstance(param, (tuple, list)):
param = (param[0], False, param[1])
sdir = os.path.dirname(os.path.realpath(request.fspath))
async with achdir(sdir, "unet_share fixture"):
async for x in _unet_impl(
rundir_module, pytestconfig, unshare=False, param=param
):
yield x
@pytest.fixture(scope="module")
async def unet_unshare(request, rundir_module, pytestconfig): # pylint: disable=W0621
"""A unet creating fixutre.
This unshare variant has the top level munet unshare the process inline so that
root level commands and actions are execute in a new namespace. This allows things
like scapy inline in tests to work.
The request param is either the basename of the config file or a tuple of the form:
(basename, top_level_pidns), the second value is a boolean.
"""
param = request.param if hasattr(request, "param") else None
if isinstance(param, (tuple, list)):
param = (param[0], True, param[1])
sdir = os.path.dirname(os.path.realpath(request.fspath))
async with achdir(sdir, "unet_unshare fixture"):
async for x in _unet_impl(
rundir_module, pytestconfig, unshare=True, param=param
):
yield x
# =================
# Function Fixtures
# =================
@pytest.fixture(autouse=True, scope="function")
async def function_autouse(request):
async with achdir(
os.path.dirname(os.path.realpath(request.fspath)), "func.fixture"
):
yield
@pytest.fixture(autouse=True)
async def check_for_pause(request, pytestconfig):
# When we unshare inline we can't pause in the pytest_runtest_makereport hook
# so do it here.
if BaseMunet.g_unet and BaseMunet.g_unet.unshare_inline:
pause = bool(pytestconfig.getoption("--pause"))
if pause:
await async_pause_test(f"XXX before test '{request.node.name}'")
yield
@pytest.fixture(scope="function")
def stepf(pytestconfig):
class Stepnum:
"""Track the stepnum in closure."""
num = 0
def inc(self):
self.num += 1
pause = pytestconfig.getoption("pause")
stepnum = Stepnum()
def stepfunction(desc=""):
desc = f": {desc}" if desc else ""
if pause:
pause_test(f"before step {stepnum.num}{desc}")
logging.info("STEP %s%s", stepnum.num, desc)
stepnum.inc()
return stepfunction
@pytest_asyncio.fixture(scope="function")
async def astepf(pytestconfig):
class Stepnum:
"""Track the stepnum in closure."""
num = 0
def inc(self):
self.num += 1
pause = pytestconfig.getoption("pause")
stepnum = Stepnum()
async def stepfunction(desc=""):
desc = f": {desc}" if desc else ""
if pause:
await async_pause_test(f"before step {stepnum.num}{desc}")
logging.info("STEP %s%s", stepnum.num, desc)
stepnum.inc()
return stepfunction
@pytest.fixture(scope="function")
def rundir():
root_path = os.environ.get("MUNET_RUNDIR", "/tmp/unet-test")
d = os.path.join(root_path, get_test_logdir(module=False))
logging.debug("conftest: test function rundir %s", d)
return d
# Configure logging
@pytest.hookimpl(hookwrapper=True, tryfirst=True)
def pytest_runtest_setup(item):
root_path = os.environ.get("MUNET_RUNDIR", "/tmp/unet-test")
d = os.path.join(root_path, get_test_logdir(nodeid=item.nodeid, module=False))
config = item.config
logging_plugin = config.pluginmanager.get_plugin("logging-plugin")
filename = Path(d, "pytest-exec.log")
logging_plugin.set_log_path(str(filename))
logging.debug("conftest: test function setup: rundir %s", d)
yield
@pytest.fixture
async def unet_perfunc(request, rundir, pytestconfig): # pylint: disable=W0621
param = request.param if hasattr(request, "param") else None
async for x in _unet_impl(rundir, pytestconfig, param=param):
yield x
@pytest.fixture
async def unet_perfunc_unshare(request, rundir, pytestconfig): # pylint: disable=W0621
"""Build unet per test function with an optional topology basename parameter.
The fixture can be parameterized to choose different config files.
For example, use as follows to run the test with unet_perfunc configured
first with a config file named `cfg1.yaml` then with config file `cfg2.yaml`
(where the actual files could end with `json` or `toml` rather than `yaml`).
@pytest.mark.parametrize(
"unet_perfunc", ["cfg1", "cfg2]", indirect=["unet_perfunc"]
)
def test_example(unet_perfunc)
"""
param = request.param if hasattr(request, "param") else None
async for x in _unet_impl(rundir, pytestconfig, unshare=True, param=param):
yield x
@pytest.fixture
async def unet_perfunc_share(request, rundir, pytestconfig): # pylint: disable=W0621
"""Build unet per test function with an optional topology basename parameter.
This share variant keeps munet from unsharing the process to a new namespace so that
root level commands and actions are execute on the host, normally they are executed
in the munet namespace which allowing things like scapy inline in tests to work.
The fixture can be parameterized to choose different config files. For example, use
as follows to run the test with unet_perfunc configured first with a config file
named `cfg1.yaml` then with config file `cfg2.yaml` (where the actual files could
end with `json` or `toml` rather than `yaml`).
@pytest.mark.parametrize(
"unet_perfunc", ["cfg1", "cfg2]", indirect=["unet_perfunc"]
)
def test_example(unet_perfunc)
"""
param = request.param if hasattr(request, "param") else None
async for x in _unet_impl(rundir, pytestconfig, unshare=False, param=param):
yield x