From 6878f164d0be55ba7bff293460567307b3f5a4e1 Mon Sep 17 00:00:00 2001 From: "Bradley A. Thornton" Date: Tue, 9 Jul 2024 16:02:47 -0700 Subject: [PATCH 1/4] Container testing framework --- .config/dictionary.txt | 3 + tests/conftest.py | 247 +++++++++++++++++++++++++--- tests/integration/test_container.py | 21 +++ 3 files changed, 250 insertions(+), 21 deletions(-) create mode 100644 tests/integration/test_container.py diff --git a/.config/dictionary.txt b/.config/dictionary.txt index 7ab3502..70c0b3d 100644 --- a/.config/dictionary.txt +++ b/.config/dictionary.txt @@ -1,4 +1,6 @@ accesslog +addinivalue +addoption adt ansibuddy antsibull @@ -13,6 +15,7 @@ endgroup gunicorn libera microdnf +modifyitems netcommon pkgmgr pylibssh diff --git a/tests/conftest.py b/tests/conftest.py index dd8f40d..f136d50 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,10 +18,13 @@ """ import os +import shutil import subprocess import sys import time +from collections.abc import Callable +from dataclasses import dataclass from pathlib import Path import pytest @@ -30,7 +33,134 @@ import ansible_dev_tools # noqa: F401 -PROC: None | subprocess.Popen[bytes] = None +@dataclass +class Infrastructure: + """Structure for instance infrastructure. + + Attributes: + session: The pytest session + container_engine: The container engine + container_name: The container name + container: Container required + image_name: The image name + include_container: Include container tests + only_container: Only container tests + proc: The server process + server: Server required + """ + + session: pytest.Session + container_engine: str = "" + container_name: str = "" + container: bool = False + image_name: str = "" + include_container: bool = False + only_container: bool = False + proc: None | subprocess.Popen[bytes] = None + server: bool = False + + def __post_init__(self) -> None: + """Initialize the infrastructure. + + Raises: + ValueError: If the container engine is not found. + ValueError: If the container name is not set. + ValueError: If both only_container and include_container are set. + """ + self.container_engine = self.session.config.getoption("--container-engine") + self.container_name = self.session.config.getoption("--container-name", "") + self.image_name = self.session.config.getoption("--image-name", "") + self.include_container = self.session.config.getoption("--include-container") + self.only_container = self.session.config.getoption("--only-container") + if self.only_container or self.include_container: + if not self.container_name: + err = "ADT_CONTAINER_NAME must be set for container tests" + raise ValueError(err) + if not self.container_engine: + err = "No container engine found, required for container tests" + raise ValueError(err) + elif self.only_container and self.include_container: + err = "Cannot use both --only-container and --include-container" + raise ValueError(err) + + if self.only_container: + self.server = False + self.container = True + elif self.include_container: + self.server = True + self.container = True + + +INFRASTRUCTURE: Infrastructure + + +def pytest_addoption(parser: pytest.Parser) -> None: + """Add options to pytest. + + Args: + parser: The pytest parser. + """ + parser.addoption( + "--container-engine", + action="store", + default=os.environ.get( + "ADT_CONTAINER_ENGINE", + shutil.which("podman") or shutil.which("docker") or "", + ), + help="Container engine to use. (default=ADT_CONTAINER_ENGINE, podman, docker, '')", + ) + parser.addoption( + "--container-name", + action="store", + default=os.environ.get("ADT_CONTAINER_NAME", "adt-test-container"), + help="Container name to use for the running container. (default=ADT_CONTAINER_NAME)", + ) + parser.addoption( + "--image-name", + action="store", + default=os.environ.get("ADT_IMAGE_NAME", ""), + help="Container name to use. (default=ADT_IMAGE_NAME)", + ) + parser.addoption( + "--only-container", + action="store_true", + default=False, + help="Only run container tests", + ) + parser.addoption( + "--include-container", + action="store_true", + default=False, + help="Include container tests", + ) + + +def pytest_configure(config: pytest.Config) -> None: + """Configure pytest. + + Args: + config: The pytest configuration. + """ + config.addinivalue_line("markers", "container: container tests") + + +def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: + """Modify the collection of items. + + Args: + config: The pytest configuration. + items: The list of items. + """ + if config.getoption("--only-container"): + skip_container = pytest.mark.skip(reason="--only-container specified") + for item in items: + if "container" not in item.keywords: + item.add_marker(skip_container) + elif not config.getoption("--include-container"): + skip_container = pytest.mark.skip(reason="need --include-container option to run") + for item in items: + if "container" in item.keywords: + item.add_marker(skip_container) @pytest.fixture(scope="session") @@ -48,18 +178,101 @@ def pytest_sessionstart(session: pytest.Session) -> None: Args: session: The pytest session. - - Raises: - RuntimeError: If the server could not be started. """ assert session - bin_path = Path(sys.executable).parent / "adt" if os.environ.get("PYTEST_XDIST_WORKER"): return - global PROC # noqa: PLW0603 - PROC = subprocess.Popen( # noqa: S603 + global INFRASTRUCTURE # noqa: PLW0603 + + INFRASTRUCTURE = Infrastructure(session) + + if INFRASTRUCTURE.container: + _start_container() + if INFRASTRUCTURE.server: + _start_server() + + +def pytest_sessionfinish(session: pytest.Session) -> None: + """Stop the server. + + Args: + session: The pytest session. + """ + assert session + if os.environ.get("PYTEST_XDIST_WORKER"): + return + + if INFRASTRUCTURE.container: + _stop_container() + if INFRASTRUCTURE.server: + _stop_server() + + +def _start_container() -> None: + """Start the container.""" + cmd = [ + INFRASTRUCTURE.container_engine, + "run", + "-d", + "--rm", + "--name", + INFRASTRUCTURE.container_name, + INFRASTRUCTURE.image_name, + "sleep", + "infinity", + ] + subprocess.run(cmd, check=True, capture_output=True) # noqa: S603 + + +def _stop_container() -> None: + """Stop the container.""" + cmd = [ + INFRASTRUCTURE.container_engine, + "stop", + INFRASTRUCTURE.container_name, + ] + subprocess.run(cmd, check=True, capture_output=True) # noqa: S603 + + +def _exec_container(command: str) -> subprocess.CompletedProcess[str]: + """Run the container. + + Args: + command: The command to run + + Returns: + subprocess.CompletedProcess: The completed process. + """ + cmd = f"{INFRASTRUCTURE.container_engine} exec -it {INFRASTRUCTURE.container_name} {command}" + return subprocess.run( + cmd, + check=True, + capture_output=True, + text=True, + shell=True, + ) + + +@pytest.fixture() +def exec_container() -> Callable[[str], subprocess.CompletedProcess[str]]: + """Run the container. + + Returns: + callable: The container executor. + """ + return _exec_container + + +def _start_server() -> None: + """Start the server. + + Raises: + RuntimeError: If the server could not be started. + """ + bin_path = Path(sys.executable).parent / "adt" + INFRASTRUCTURE.proc = subprocess.Popen( # noqa: S603 [bin_path, "server", "-p", "8000"], env=os.environ, ) @@ -78,23 +291,15 @@ def pytest_sessionstart(session: pytest.Session) -> None: raise RuntimeError(msg) -def pytest_sessionfinish(session: pytest.Session) -> None: +def _stop_server() -> None: """Stop the server. - Args: - session: The pytest session. - Raises: - RuntimeError: If the server could not be stopped. + RuntimeError: If the server is not running. """ - assert session - if os.environ.get("PYTEST_XDIST_WORKER"): - return - - global PROC # noqa: PLW0603 - if PROC is None: + if INFRASTRUCTURE.proc is None: msg = "The server is not running." raise RuntimeError(msg) - PROC.terminate() - PROC.wait() - PROC = None + INFRASTRUCTURE.proc.terminate() + INFRASTRUCTURE.proc.wait() + INFRASTRUCTURE.proc = None diff --git a/tests/integration/test_container.py b/tests/integration/test_container.py new file mode 100644 index 0000000..66bae4a --- /dev/null +++ b/tests/integration/test_container.py @@ -0,0 +1,21 @@ +"""Run tests against the container.""" + +import subprocess + +from collections.abc import Callable + +import pytest + +from ansible_dev_tools.version_builder import PKGS + + +@pytest.mark.container() +def test_versions(exec_container: Callable[[str], subprocess.CompletedProcess[str]]) -> None: + """Test the versions. + + Args: + exec_container: The container executor. + """ + versions = exec_container("adt --version") + for pkg in PKGS: + assert pkg in versions.stdout, f"{pkg} not found in version output" From 13e3c91476425d052052153f45fb2d2b33cdbd81 Mon Sep 17 00:00:00 2001 From: "Bradley A. Thornton" Date: Wed, 10 Jul 2024 05:18:40 -0700 Subject: [PATCH 2/4] Start server --- tests/conftest.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index f136d50..b5656c6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -84,11 +84,14 @@ def __post_init__(self) -> None: raise ValueError(err) if self.only_container: - self.server = False self.container = True + self.server = False elif self.include_container: - self.server = True self.container = True + self.server = True + else: + self.container = False + self.server = True INFRASTRUCTURE: Infrastructure From aa70f7365f79c884b9ac419d2ecd9ad8692c4a9e Mon Sep 17 00:00:00 2001 From: "Bradley A. Thornton" Date: Wed, 10 Jul 2024 07:07:36 -0700 Subject: [PATCH 3/4] Add specific commands for podman and docker --- tests/conftest.py | 58 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index b5656c6..3c7cf14 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -213,20 +213,52 @@ def pytest_sessionfinish(session: pytest.Session) -> None: _stop_server() +PODMAN_CMD = """{container_engine} run -d --rm + --cap-add=SYS_ADMIN + --cap-add=SYS_RESOURCE + --device "/dev/fuse" + --hostname=ansible-dev-container + --name={container_name} + --security-opt "apparmor=unconfined" + --security-opt "label=disable" + --security-opt "seccomp=unconfined" + --user=root + --userns=host + -v $PWD:/workdir + {image_name} + sleep infinity""" + +DOCKER_CMD = """{container_engine} run -d --rm + --cap-add=SYS_ADMIN + --cap-add=SYS_RESOURCE + --device "/dev/fuse" + --hostname=ansible-dev-container + --name={container_name} + --security-opt "apparmor=unconfined" + --security-opt "label=disable" + --security-opt "seccomp=unconfined" + --user=podman + -v $PWD:/workdir + {image_name} + sleep infinity""" + + def _start_container() -> None: """Start the container.""" - cmd = [ - INFRASTRUCTURE.container_engine, - "run", - "-d", - "--rm", - "--name", - INFRASTRUCTURE.container_name, - INFRASTRUCTURE.image_name, - "sleep", - "infinity", - ] - subprocess.run(cmd, check=True, capture_output=True) # noqa: S603 + if "podman" in INFRASTRUCTURE.container_engine: + cmd = PODMAN_CMD.format( + container_engine=INFRASTRUCTURE.container_engine, + container_name=INFRASTRUCTURE.container_name, + image_name=INFRASTRUCTURE.image_name, + ) + elif "docker" in INFRASTRUCTURE.container_engine: + cmd = DOCKER_CMD.format( + container_engine=INFRASTRUCTURE.container_engine, + container_name=INFRASTRUCTURE.container_name, + image_name=INFRASTRUCTURE.image_name, + ) + cmd = cmd.replace("\n", " ") + subprocess.run(cmd, check=True, capture_output=True, shell=True) def _stop_container() -> None: @@ -248,7 +280,7 @@ def _exec_container(command: str) -> subprocess.CompletedProcess[str]: Returns: subprocess.CompletedProcess: The completed process. """ - cmd = f"{INFRASTRUCTURE.container_engine} exec -it {INFRASTRUCTURE.container_name} {command}" + cmd = f"{INFRASTRUCTURE.container_engine} exec -t {INFRASTRUCTURE.container_name} {command}" return subprocess.run( cmd, check=True, From 4a0909e7223024c595db3ba3af44fd6f54b7cd04 Mon Sep 17 00:00:00 2001 From: "Bradley A. Thornton" Date: Wed, 10 Jul 2024 07:14:16 -0700 Subject: [PATCH 4/4] pylint fix --- tests/conftest.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 3c7cf14..3de8451 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -244,7 +244,11 @@ def pytest_sessionfinish(session: pytest.Session) -> None: def _start_container() -> None: - """Start the container.""" + """Start the container. + + Raises: + ValueError: If the container engine is not podman or docker. + """ if "podman" in INFRASTRUCTURE.container_engine: cmd = PODMAN_CMD.format( container_engine=INFRASTRUCTURE.container_engine, @@ -257,6 +261,9 @@ def _start_container() -> None: container_name=INFRASTRUCTURE.container_name, image_name=INFRASTRUCTURE.image_name, ) + else: + err = f"Container engine {INFRASTRUCTURE.container_engine} not found." + raise ValueError(err) cmd = cmd.replace("\n", " ") subprocess.run(cmd, check=True, capture_output=True, shell=True)