diff --git a/README.rst b/README.rst index 531f44f0..6ea7446b 100644 --- a/README.rst +++ b/README.rst @@ -21,7 +21,7 @@ Find the latest documentation on `dcermak.github.io/pytest_container `_. ``pytest_container`` is a `pytest `_ plugin -to test container images via pytest fixtures and `testinfra +to test container images via pytest fixtures and optionally `testinfra `_. It takes care of all the boring tasks, like spinning up containers, finding free ports and cleaning up after tests, and allows you to focus on implementing the actual tests. @@ -55,8 +55,7 @@ instantiating a ``Container`` and parametrizing a test function with the The fixture automatically pulls and spins up the container, stops it and removes it after the test is completed. Your test function receives an instance of ``ContainerData`` with the ``ContainerData.connection`` attribute. The -``ContainerData.connection`` attribute is a `testinfra -`_ connection object. It can be +``ContainerData.connection`` attribute is a shell connection object. It can be used to run basic tests inside the container itself. For example, you can check whether files are present, packages are installed, etc. diff --git a/pytest_container/container.py b/pytest_container/container.py index 961fdd74..8bac9ae8 100644 --- a/pytest_container/container.py +++ b/pytest_container/container.py @@ -43,10 +43,10 @@ import _pytest.mark import deprecation import pytest -import testinfra from filelock import BaseFileLock from filelock import FileLock +from pytest_container import helpers from pytest_container.helpers import get_always_pull_option from pytest_container.helpers import get_extra_build_args from pytest_container.helpers import get_extra_run_args @@ -877,7 +877,7 @@ def prepare_container( @dataclass(frozen=True) class ContainerData: """Class returned by the ``*container*`` fixtures to the test function. It - contains information about the launched container and the testinfra + contains information about the launched container and its shell connection :py:attr:`connection` to the running container. """ @@ -887,7 +887,7 @@ class ContainerData: image_url_or_id: str #: ID of the started container container_id: str - #: the testinfra connection to the running container + #: the shell connection to the running container connection: Any #: the container data class that has been used in this test container: Union[Container, DerivedContainer] @@ -1013,6 +1013,28 @@ def container_from_pytest_param( raise ValueError(f"Invalid pytest.param values: {param.values}") +@dataclass(frozen=True) +class ContainerRemoteEndpoint: + _container_id: str + _runtime: OciRuntimeBase + + def __post_init__(self) -> None: + assert self._container_id, "Container ID must not be empty" + + def check_output(self, cmd: str, strip: bool = True) -> str: + """Run a command in the container and return its output.""" + return helpers.run_command( + [ + self._runtime.runner_binary, + "exec", + self._container_id, + "/bin/sh", + "-c", + cmd, + ], + )[1] + + @dataclass class ContainerLauncher: """Helper context manager to setup, start and teardown a container including @@ -1174,12 +1196,22 @@ def container_data(self) -> ContainerData: """ if not self._container_id: raise RuntimeError(f"Container {self.container} has not started") + connection: Any = None + try: + import testinfra + + connection = testinfra.get_host( + f"{self.container_runtime.runner_binary}://{self._container_id}" + ) + except ImportError: + connection = ContainerRemoteEndpoint( + self._container_id, self.container_runtime + ) + return ContainerData( image_url_or_id=self.container.url or self.container.container_id, container_id=self._container_id, - connection=testinfra.get_host( - f"{self.container_runtime.runner_binary}://{self._container_id}" - ), + connection=connection, container=self.container, forwarded_ports=self._new_port_forwards, _container_runtime=self.container_runtime, diff --git a/pytest_container/helpers.py b/pytest_container/helpers.py index 073549f7..f1e47ab2 100644 --- a/pytest_container/helpers.py +++ b/pytest_container/helpers.py @@ -6,12 +6,15 @@ import logging import os +import subprocess from typing import List +from typing import Tuple from _pytest.config import Config from _pytest.config.argparsing import Parser from _pytest.python import Metafunc +from pytest_container.logging import _logger from pytest_container.logging import set_internal_logging_level @@ -157,3 +160,26 @@ def get_always_pull_option() -> bool: """ return bool(int(os.getenv("PULL_ALWAYS", "1"))) + + +def run_command( + cmd: List[str], + ignore_errors=True, +) -> Tuple[int, str, str]: + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + check=not ignore_errors, + ) + + _logger.debug( + f"RUN CMD: {cmd} RC: {result.returncode} STDOUT: {result.stdout} STDERR:{result.stderr}" + ) + return result.returncode, result.stdout, result.stderr + except subprocess.CalledProcessError as exc: + _logger.debug( + f"RUN(Failed) CMD: {cmd} RC: {exc.returncode} STDOUT:{exc.stdout} STDERR:{exc.stderr}" + ) + raise exc diff --git a/pytest_container/runtime.py b/pytest_container/runtime.py index 6c72333b..798478fd 100644 --- a/pytest_container/runtime.py +++ b/pytest_container/runtime.py @@ -6,6 +6,7 @@ import json import re +import shutil import sys from abc import ABC from abc import abstractmethod @@ -20,10 +21,10 @@ from typing import Optional from typing import Union -import testinfra from _pytest.mark.structures import ParameterSet from pytest import param +from pytest_container import helpers from pytest_container.inspect import BindMount from pytest_container.inspect import Config from pytest_container.inspect import ContainerHealth @@ -411,9 +412,6 @@ def __str__(self) -> str: return self.__class__.__name__ -LOCALHOST = testinfra.host.get_host("local://") - - def _get_podman_version(version_stdout: str) -> Version: if version_stdout[:15] != "podman version ": raise RuntimeError( @@ -424,7 +422,7 @@ def _get_podman_version(version_stdout: str) -> Version: def _get_buildah_version() -> Version: - version_stdout = LOCALHOST.check_output("buildah --version") + version_stdout = helpers.run_command(["buildah", "--version"])[1] build_version_begin = "buildah version " if not version_stdout.startswith(build_version_begin): raise RuntimeError( @@ -443,11 +441,11 @@ class PodmanRuntime(OciRuntimeBase): """ def __init__(self) -> None: - podman_ps = LOCALHOST.run("podman ps") - if not podman_ps.succeeded: - raise RuntimeError(f"`podman ps` failed with {podman_ps.stderr}") + podman_ps = helpers.run_command(["podman", "ps"]) + if not podman_ps[0] == 0: + raise RuntimeError(f"`podman ps` failed with {podman_ps[2]}") - self._buildah_functional = LOCALHOST.run("buildah").succeeded + self._buildah_functional = helpers.run_command(["buildah"])[0] == 0 super().__init__( build_command=( ["buildah", "bud", "--layers", "--force-rm"] @@ -461,8 +459,11 @@ def __init__(self) -> None: @cached_property def version(self) -> Version: """Returns the version of podman installed on the system""" + return _get_podman_version( - LOCALHOST.run_expect([0], "podman --version").stdout + helpers.run_command(["podman", "--version"], ignore_errors=False)[ + 1 + ] ) @cached_property @@ -539,9 +540,9 @@ class DockerRuntime(OciRuntimeBase): containers.""" def __init__(self) -> None: - docker_ps = LOCALHOST.run("docker ps") - if not docker_ps.succeeded: - raise RuntimeError(f"`docker ps` failed with {docker_ps.stderr}") + docker_ps = helpers.run_command(["docker", "ps"]) + if not docker_ps[0] == 0: + raise RuntimeError(f"`docker ps` failed with {docker_ps[2]}") super().__init__( build_command=["docker", "build", "--force-rm"], @@ -552,7 +553,9 @@ def __init__(self) -> None: def version(self) -> Version: """Returns the version of docker installed on this system""" return _get_docker_version( - LOCALHOST.run_expect([0], "docker --version").stdout + helpers.run_command(["docker", "--version"], ignore_errors=False)[ + 1 + ] ) @property @@ -613,8 +616,8 @@ def get_selected_runtime() -> OciRuntimeBase: If neither docker nor podman are available, then a ValueError is raised. """ - podman_exists = LOCALHOST.exists("podman") - docker_exists = LOCALHOST.exists("docker") + podman_exists = shutil.which("podman") + docker_exists = shutil.which("docker") runtime_choice = getenv("CONTAINER_RUNTIME", "podman").lower() if runtime_choice not in ("podman", "docker"): diff --git a/test-requirements.txt b/test-requirements.txt index 323c791d..c5a01c15 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -3,4 +3,5 @@ pytest-xdist coverage pytest-rerunfailures typeguard +ifaddr . diff --git a/tests/test_container_build.py b/tests/test_container_build.py index 155ee7af..66f13451 100644 --- a/tests/test_container_build.py +++ b/tests/test_container_build.py @@ -13,8 +13,8 @@ from pytest_container.container import ContainerData from pytest_container.container import ContainerLauncher from pytest_container.container import EntrypointSelection +from pytest_container.helpers import run_command from pytest_container.inspect import PortForwarding -from pytest_container.runtime import LOCALHOST from pytest_container.runtime import OciRuntimeBase from .images import LEAP @@ -285,9 +285,9 @@ def test_multistage_build_target( extra_build_args=get_extra_build_args(pytestconfig), ) assert ( - LOCALHOST.check_output( - f"{container_runtime.runner_binary} run --rm {first_target}", - ).strip() + run_command( + [container_runtime.runner_binary, "run", "--rm", first_target] + )[1].strip() == "foobar" ) @@ -301,9 +301,15 @@ def test_multistage_build_target( assert first_target != second_target assert ( - LOCALHOST.check_output( - f"{container_runtime.runner_binary} run --rm {second_target} /bin/test.sh", - ).strip() + run_command( + [ + container_runtime.runner_binary, + "run", + "--rm", + second_target, + "/bin/test.sh", + ] + )[1].strip() == "foobar" ) @@ -313,10 +319,17 @@ def test_multistage_build_target( ): assert ( distro - in LOCALHOST.check_output( - f"{container_runtime.runner_binary} run --rm --entrypoint= {target} " - "cat /etc/os-release", - ).strip() + in run_command( + [ + container_runtime.runner_binary, + "run", + "--rm", + "--entrypoint=", + target, + "cat", + "/etc/os-release", + ] + )[1].strip() ) diff --git a/tests/test_launcher.py b/tests/test_launcher.py index 3814cd4d..cb5aedc3 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -19,7 +19,7 @@ from pytest_container.container import ContainerVolume from pytest_container.container import DerivedContainer from pytest_container.container import EntrypointSelection -from pytest_container.runtime import LOCALHOST +from pytest_container.helpers import run_command from pytest_container.runtime import OciRuntimeBase from .images import CMDLINE_APP_CONTAINER @@ -101,9 +101,17 @@ def test_launcher_creates_and_cleanes_up_volumes( assert vol.host_path and os.path.exists(vol.host_path) elif isinstance(vol, ContainerVolume): assert vol.volume_id - assert LOCALHOST.run_expect( - [0], - f"{container_runtime.runner_binary} volume inspect {vol.volume_id}", + assert ( + run_command( + [ + container_runtime.runner_binary, + "volume", + "inspect", + vol.volume_id, + ], + ignore_errors=False, + )[0] + == 0 ) else: assert False, f"invalid volume type {type(vol)}" diff --git a/tests/test_port_forwarding.py b/tests/test_port_forwarding.py index e37fbef9..4756abce 100644 --- a/tests/test_port_forwarding.py +++ b/tests/test_port_forwarding.py @@ -2,10 +2,11 @@ :py:attr:`~pytest_container.container.ContainerBase.forwarded_ports`.""" # pylint: disable=missing-function-docstring -import itertools +import re import socket from typing import List +import ifaddr import pytest from pytest_container.container import ContainerData @@ -13,10 +14,10 @@ from pytest_container.container import DerivedContainer from pytest_container.container import PortForwarding from pytest_container.container import lock_host_port_search +from pytest_container.helpers import run_command from pytest_container.inspect import NetworkProtocol from pytest_container.pod import Pod from pytest_container.pod import PodLauncher -from pytest_container.runtime import LOCALHOST from pytest_container.runtime import OciRuntimeBase from pytest_container.runtime import Version @@ -65,8 +66,12 @@ def _create_nginx_container(number: int) -> DerivedContainer: CONTAINER_IMAGES = [WEB_SERVER] +curl_version_string = run_command(["curl", "--version"])[1] +pattern = r"curl\s+(\d+(?:\.\d+)+)" +match = re.search(pattern, curl_version_string) +assert match is not None, "Curl version not found" +_curl_version = Version.parse(match.group(1)) -_curl_version = Version.parse(LOCALHOST.package("curl").version) #: curl cli with additional retries as a single curl sometimes fails with docker #: with ``curl: (56) Recv failure: Connection reset by peer`` for reasons… @@ -175,18 +180,18 @@ def test_multiple_open_ports(container: ContainerData, number: int, host): ) -_INTERFACES = [ - name - for name in LOCALHOST.interface.names() - if name[:2] in ("en", "et", "wl") -] -_ADDRESSES = [ - addr - for addr in itertools.chain.from_iterable( - LOCALHOST.interface(interface).addresses for interface in _INTERFACES - ) - if not addr.startswith("169.254.") and not addr.startswith("fe80:") -] +def _find_all_usable_ips(): + for adapter in ifaddr.get_adapters(): + if not adapter.name.startswith(("en", "et", "wl")): + continue + for addr in adapter.ips: + if addr.is_IPv4 and not addr.ip.startswith("169.254."): + yield str(addr.ip) + elif addr.is_IPv6 and not addr.ip[0].startswith("fe80:"): + yield addr.ip[0] + + +_ADDRESSES = list(_find_all_usable_ips()) @pytest.mark.parametrize( diff --git a/tests/test_runtime.py b/tests/test_runtime.py index b9955f4f..4d15c622 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -1,5 +1,6 @@ # pylint: disable=missing-function-docstring,missing-module-docstring import os +import shutil from pathlib import Path from typing import Callable from typing import Type @@ -8,7 +9,7 @@ import pytest -from pytest_container.runtime import LOCALHOST +from pytest_container import helpers from pytest_container.runtime import DockerRuntime from pytest_container.runtime import OciRuntimeBase from pytest_container.runtime import PodmanRuntime @@ -27,45 +28,6 @@ def container_runtime_envvar(request): yield -# pylint: disable-next=unused-argument -def _mock_run_success(*args, **kwargs): - class Succeeded: - """Class that mocks the returned object of `testinfra`'s `run`.""" - - @property - def succeeded(self) -> bool: - return True - - @property - def rc(self) -> int: - return 0 - - return Succeeded() - - -def generate_mock_fail(*, rc: int = 1, stderr: str = "failure!!"): - # pylint: disable-next=unused-argument - def mock_run_fail(cmd: str): - class Failure: - """Class that mocks the returned object of `testinfra`'s `run`.""" - - @property - def succeeded(self) -> bool: - return False - - @property - def rc(self) -> int: - return rc - - @property - def stderr(self) -> str: - return stderr - - return Failure() - - return mock_run_fail - - def _create_mock_exists( podman_should_exist: bool, docker_should_exist: bool ) -> Callable[[str], bool]: @@ -96,8 +58,7 @@ def test_runtime_selection( runtime: OciRuntimeBase, monkeypatch: pytest.MonkeyPatch, ): - monkeypatch.setattr(LOCALHOST, "run", _mock_run_success) - monkeypatch.setattr(LOCALHOST, "exists", _create_mock_exists(True, True)) + monkeypatch.setattr(shutil, "which", _create_mock_exists(True, True)) assert get_selected_runtime() == runtime @@ -107,7 +68,7 @@ def test_value_err_when_docker_and_podman_missing( runtime: str, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("CONTAINER_RUNTIME", runtime) - monkeypatch.setattr(LOCALHOST, "exists", _create_mock_exists(False, False)) + monkeypatch.setattr(shutil, "which", _create_mock_exists(False, False)) with pytest.raises(ValueError) as val_err_ctx: get_selected_runtime() @@ -125,7 +86,11 @@ def test_runtime_construction_fails_if_ps_fails( monkeypatch: pytest.MonkeyPatch, ) -> None: stderr = "container runtime failed" - monkeypatch.setattr(LOCALHOST, "run", generate_mock_fail(stderr=stderr)) + monkeypatch.setattr( + helpers, + "run_command", + lambda _: (1, "", stderr), + ) with pytest.raises(RuntimeError) as rt_err_ctx: cls() @@ -145,7 +110,9 @@ def test_buildah_version_parsing( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr( - LOCALHOST, "check_output", lambda _: f"buildah version {version_str}" + helpers, + "run_command", + lambda _: (0, f"buildah version {version_str}", ""), ) assert _get_buildah_version() == expected_version @@ -154,7 +121,7 @@ def test_buildah_version_parsing( def test_get_buildah_version_fails_on_unexpected_stdout( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(LOCALHOST, "check_output", lambda _: "foobar") + monkeypatch.setattr(helpers, "run_command", lambda _: (0, "foobar", "")) with pytest.raises(RuntimeError) as rt_err_ctx: _get_buildah_version() diff --git a/tests/test_volumes.py b/tests/test_volumes.py index a4360b63..675ec0a5 100644 --- a/tests/test_volumes.py +++ b/tests/test_volumes.py @@ -2,6 +2,7 @@ import os from os.path import abspath from os.path import join +from pathlib import Path from typing import List import pytest @@ -14,7 +15,6 @@ from pytest_container.container import DerivedContainer from pytest_container.container import VolumeFlag from pytest_container.container import get_volume_creator -from pytest_container.runtime import LOCALHOST from pytest_container.runtime import OciRuntimeBase from .images import LEAP_URL @@ -118,8 +118,8 @@ def test_container_host_volumes(container_per_test: ContainerData): for vol in container_per_test.container.volume_mounts: assert isinstance(vol, BindMount) assert vol.host_path - dir_on_host = LOCALHOST.file(vol.host_path) - assert dir_on_host.exists and dir_on_host.is_directory + dir_on_host = Path(vol.host_path) + assert dir_on_host.is_dir() dir_in_container = container_per_test.connection.file( vol.container_path @@ -142,8 +142,8 @@ def test_container_volume_host_writing(container_per_test: ContainerData): assert isinstance(vol, BindMount) assert vol.host_path - host_dir = LOCALHOST.file(vol.host_path) - assert not host_dir.listdir() + host_dir = Path(vol.host_path) + assert not list(host_dir.iterdir()) container_dir = container_per_test.connection.file(vol.container_path) assert not container_dir.listdir() @@ -265,9 +265,14 @@ def test_concurrent_container_volumes(container_per_test: ContainerData): def test_bind_mount_cwd(container: ContainerData): vol = container.container.volume_mounts[0] assert isinstance(vol, BindMount) + assert vol.host_path is not None + assert container.connection.file("/src/").exists and sorted( container.connection.file("/src/").listdir() - ) == sorted(LOCALHOST.file(vol.host_path).listdir()) + ) == sorted( + str(p.relative_to(vol.host_path)) + for p in Path(vol.host_path).iterdir() + ) def test_bind_mount_fails_when_host_path_not_present() -> None: