From aebac9d1b6be76afbf1aeedc76a8632eaeac8071 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20=C4=8Cerm=C3=A1k?= Date: Fri, 23 Aug 2024 14:53:37 +0200 Subject: [PATCH] Add new fixture container_image --- CHANGELOG.rst | 12 ++-- pytest_container/container.py | 103 ++++++++++++++++++++++++++++++---- pytest_container/plugin.py | 29 ++++++++++ tests/test_container_run.py | 37 ++++++++++++ 4 files changed, 165 insertions(+), 16 deletions(-) create mode 100644 tests/test_container_run.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 05a286e..c006470 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -225,12 +225,12 @@ Improvements and new features: Container Images exposing the same ports in parallel without marking them as ``singleton=True``. -- The attribute :py:attr:`~pytest_container.container.ContainerData.container` - was added to :py:class:`~pytest_container.container.ContainerData` (the - datastructure that is passed to test functions via the ``*container*`` - fixtures). This attribute contains the - :py:class:`~pytest_container.container.ContainerBase` that was used to - parametrize this test run. +- The attribute ``ContainerData.container`` (is now + :py:attr:`~pytest_container.container.ContainerImageData.container`) was added + to :py:class:`~pytest_container.container.ContainerData` (the datastructure + that is passed to test functions via the ``*container*`` fixtures). This + attribute contains the :py:class:`~pytest_container.container.ContainerBase` + that was used to parametrize this test run. - Add support to add tags to container images via ``DerivedContainer.add_build_tags`` (is now called diff --git a/pytest_container/container.py b/pytest_container/container.py index c240f71..479362e 100644 --- a/pytest_container/container.py +++ b/pytest_container/container.py @@ -526,20 +526,39 @@ def get_launch_cmd( self, container_runtime: OciRuntimeBase, extra_run_args: Optional[List[str]] = None, + detach: bool = True, + interactive_tty: bool = True, + remove: bool = False, ) -> List[str]: """Returns the command to launch this container image. Args: + container_runtime: The container runtime to be used to launch this + container + extra_run_args: optional list of arguments that are added to the launch command directly after the ``run -d``. + detach: flag whether to launch the container with ``-d`` + (i.e. in the background). Defaults to ``True``. + + interactive: flag whether to launch the container with ``--it`` + (i.e. in interactive mode and attach a pseudo TTY). Defaults to + ``True``. + + remove: flag whether to launch the container with the ``--rm`` flag + (i.e. it will be auto-removed on after stopping). Defaults to + ``False``. + Returns: The command to launch the container image described by this class instance as a list of strings that can be fed directly to :py:class:`subprocess.Popen` as the ``args`` parameter. """ cmd = ( - [container_runtime.runner_binary, "run", "-d"] + [container_runtime.runner_binary, "run"] + + (["-d"] if detach else []) + + (["--rm"] if remove else []) + (extra_run_args or []) + self.extra_launch_args + ( @@ -558,7 +577,9 @@ def get_launch_cmd( ) id_or_url = self.container_id or self.url - container_launch = ("-it", id_or_url) + container_launch: Tuple[str, ...] = ( + ("-it", id_or_url) if interactive_tty else (id_or_url,) + ) bash_launch_end = ( *_CONTAINER_STOPSIGNAL, *container_launch, @@ -990,7 +1011,47 @@ def prepare_container( @dataclass(frozen=True) -class ContainerData: +class ContainerImageData: + """Class returned by the ``container_image`` fixture to the test + function. It contains a reference to the container image that has been used + in the test and has properties that provide the full command to launch the + entrypoint of the container image under test. + + """ + + #: the container data class that has been used in this test + container: Union[Container, DerivedContainer, MultiStageContainer] + + _container_runtime: OciRuntimeBase + + @property + def run_command_list(self) -> List[str]: + """The full command (including the container runtime) to launch this + container image's entrypoint in the foreground. A list of the individual + arguments is returned that can be passed directly into + :py:func:`subprocess.run`. + + """ + return self.container.get_launch_cmd( + self._container_runtime, + detach=False, + remove=True, + # -it breaks testinfra.host.check_output() with docker + interactive_tty=self._container_runtime.runner_binary == "podman", + ) + + @property + def run_command(self) -> str: + """The full command (including the container runtime) to launch this + container image's entrypoint in the foreground. The command is returned + as a single string that has to be invoked via a shell. + + """ + return " ".join(self.run_command_list) + + +@dataclass(frozen=True) +class ContainerData(ContainerImageData): """Class returned by the ``*container*`` fixtures to the test function. It contains information about the launched container and the testinfra :py:attr:`connection` to the running container. @@ -1004,13 +1065,9 @@ class ContainerData: container_id: str #: the testinfra connection to the running container connection: Any - #: the container data class that has been used in this test - container: Union[Container, DerivedContainer, MultiStageContainer] #: any ports that are exposed by this container forwarded_ports: List[PortForwarding] - _container_runtime: OciRuntimeBase - @property def inspect(self) -> ContainerInspect: """Inspect the launched container and return the result of @@ -1201,11 +1258,13 @@ def from_pytestconfig( def __enter__(self) -> "ContainerLauncher": return self - def launch_container(self) -> None: - """This function performs the actual heavy lifting of launching the - container, creating all the volumes, port bindings, etc.pp. + def prepare_container_image(self) -> None: + """Prepares the container image for launching containers. This includes + building the container image and all its dependents and creating volume + mounts. """ + # Lock guarding the container preparation, so that only one process # tries to pull/build it at the same time. # If this container is a singleton, then we use it as a lock until @@ -1247,6 +1306,13 @@ def release_lock() -> None: get_volume_creator(cont_vol, self.container_runtime) ) + def launch_container(self) -> None: + """This function performs the actual heavy lifting of launching the + container, creating all the volumes, port bindings, etc.pp. + + """ + self.prepare_container_image() + forwarded_ports = self.container.forwarded_ports extra_run_args = self.extra_run_args @@ -1286,6 +1352,23 @@ def release_lock() -> None: self._wait_for_container_to_become_healthy() + @property + def container_image_data(self) -> ContainerImageData: + """The :py:class:`ContainerImageData` belonging to this container + image. + + .. warning:: + + This property will always be set, even if the container image has not + been prepared yet. Only use it after calling + :py:func:`ContainerLauncher.prepare_container_image`. + + """ + # FIXME: check if container is prepared + return ContainerImageData( + container=self.container, _container_runtime=self.container_runtime + ) + @property def container_data(self) -> ContainerData: """The :py:class:`ContainerData` instance corresponding to the running diff --git a/pytest_container/plugin.py b/pytest_container/plugin.py index 186597c..495a72b 100644 --- a/pytest_container/plugin.py +++ b/pytest_container/plugin.py @@ -10,6 +10,7 @@ from pytest_container.container import container_and_marks_from_pytest_param from pytest_container.container import ContainerData +from pytest_container.container import ContainerImageData from pytest_container.container import ContainerLauncher from pytest_container.logging import _logger from pytest_container.pod import pod_from_pytest_param @@ -173,3 +174,31 @@ def fixture_funct( #: Same as :py:func:`pod`, except that it creates a pod for each test function. pod_per_test = _create_auto_pod_fixture("function") + + +@fixture(scope="session") +def container_image( + request: SubRequest, + # we must call this parameter container runtime, so that pytest will + # treat it as a fixture, but that causes pylint to complain… + # pylint: disable=redefined-outer-name + container_runtime: OciRuntimeBase, + pytestconfig: Config, +) -> Generator[ContainerImageData, None, None]: + """Fixture that has to be parametrized with an instance of + :py:class:`~pytest_container.container.Container`, + :py:class:`~pytest_container.container.DerivedContainer` or + :py:class:`~pytest_container.container.MultiStageContainer` with + ``indirect=True``. It builds the container image passed as the parameter and + yields an instance of + :py:class:`~pytest_container.container.ContainerImageData` to the test + function. + + """ + + container, _ = container_and_marks_from_pytest_param(request.param) + with ContainerLauncher.from_pytestconfig( + container, container_runtime, pytestconfig + ) as launcher: + launcher.prepare_container_image() + yield launcher.container_image_data diff --git a/tests/test_container_run.py b/tests/test_container_run.py new file mode 100644 index 0000000..0d8cee5 --- /dev/null +++ b/tests/test_container_run.py @@ -0,0 +1,37 @@ +# pylint: disable=missing-function-docstring,missing-module-docstring +import pytest +from pytest_container.container import ContainerImageData +from pytest_container.container import DerivedContainer + +from tests.images import LEAP + + +@pytest.mark.parametrize("container_image", [LEAP], indirect=True) +def test_run_leap(container_image: ContainerImageData, host) -> None: + assert 'NAME="openSUSE Leap"' in host.check_output( + f"{container_image.run_command} cat /etc/os-release" + ) + + +CTR_WITH_ENTRYPOINT_ADDING_PATH = DerivedContainer( + base=LEAP, + containerfile="""RUN mkdir -p /usr/test/; echo 'echo "foobar"' > /usr/test/foobar; chmod +x /usr/test/foobar +RUN echo '#!/bin/sh' > /entrypoint.sh; \ + echo "export PATH=/usr/test/:$PATH" >> /entrypoint.sh; \ + echo 'exec "$@"' >> /entrypoint.sh; \ + chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] +""", +) + + +@pytest.mark.parametrize( + "container_image", [CTR_WITH_ENTRYPOINT_ADDING_PATH], indirect=True +) +def test_entrypoint_respected_in_run( + container_image: ContainerImageData, host +) -> None: + assert "foobar" in host.check_output( + f"{container_image.run_command} foobar" + )