From 868aa2801b185a28b4ed4f64147e0ae9b1b93bd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20=C4=8Cerm=C3=A1k?= Date: Wed, 21 Aug 2024 12:10:50 +0200 Subject: [PATCH 1/6] Convert OciRuntimeBase.build_command from List -> Tuple --- CHANGELOG.rst | 3 +++ pytest_container/build.py | 2 +- pytest_container/runtime.py | 9 +++++---- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 56fa1ab4..b082e52c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -3,6 +3,9 @@ Next Release Breaking changes: +- change type of ``OciRuntimeBase.build_command`` from ``List[str]`` to + ``Tuple[str, ...]`` + - Change addition of SELinux flags to volumes: SELinux flags are only added if :py:attr:`~pytest_container.container.ContainerVolumeBase.flags` is ``None``. diff --git a/pytest_container/build.py b/pytest_container/build.py index 4dca9d51..22d25140 100644 --- a/pytest_container/build.py +++ b/pytest_container/build.py @@ -223,7 +223,7 @@ def run_build_step( with tempfile.TemporaryDirectory() as tmp_dir: iidfile = join(tmp_dir, str(uuid4())) cmd = ( - runtime.build_command + [*runtime.build_command] + (extra_build_args or []) + [f"--iidfile={iidfile}"] + (["--target", target] if target else []) diff --git a/pytest_container/runtime.py b/pytest_container/runtime.py index dd55a01a..70ebea91 100644 --- a/pytest_container/runtime.py +++ b/pytest_container/runtime.py @@ -17,6 +17,7 @@ from typing import Callable from typing import List from typing import Optional +from typing import Tuple from typing import TYPE_CHECKING from typing import Union @@ -182,7 +183,7 @@ def __gt__(self, other: Any) -> bool: @dataclass(frozen=True) class _OciRuntimeBase: #: command that builds the Dockerfile in the current working directory - build_command: List[str] = field(default_factory=list) + build_command: Tuple[str, ...] = field(default_factory=tuple) #: the "main" binary of this runtime, e.g. podman or docker runner_binary: str = "" _runtime_functional: bool = False @@ -468,9 +469,9 @@ def _runtime_error_message() -> str: def __init__(self) -> None: super().__init__( build_command=( - ["buildah", "bud", "--layers", "--force-rm"] + ("buildah", "bud", "--layers", "--force-rm") if self._buildah_functional - else ["podman", "build", "--layers", "--force-rm"] + else ("podman", "build", "--layers", "--force-rm") ), runner_binary="podman", _runtime_functional=self._runtime_functional, @@ -571,7 +572,7 @@ def _runtime_error_message() -> str: def __init__(self) -> None: super().__init__( - build_command=["docker", "build", "--force-rm"], + build_command=("docker", "build", "--force-rm"), runner_binary="docker", _runtime_functional=self._runtime_functional, ) From 5e3f9257bfbb9efb88f9b926e7b7b0be0bdaea90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20=C4=8Cerm=C3=A1k?= Date: Wed, 21 Aug 2024 12:04:22 +0200 Subject: [PATCH 2/6] Move container build into separate function --- pytest_container/container.py | 198 +++++++++++++++++++--------------- 1 file changed, 110 insertions(+), 88 deletions(-) diff --git a/pytest_container/container.py b/pytest_container/container.py index b8c34afd..3bdd2625 100644 --- a/pytest_container/container.py +++ b/pytest_container/container.py @@ -53,7 +53,6 @@ from pytest_container.inspect import PortForwarding from pytest_container.inspect import VolumeMount from pytest_container.logging import _logger -from pytest_container.runtime import get_selected_runtime from pytest_container.runtime import OciRuntimeBase if sys.version_info >= (3, 8): @@ -698,6 +697,97 @@ def baseurl(self) -> Optional[str]: return self.url +def _run_container_build( + container_runtime: OciRuntimeBase, + rootdir: Path, + containerfile: str, + parent_image_id: Optional[str] = None, + extra_build_args: Optional[Tuple[str, ...]] = None, + image_format: Optional[ImageFormat] = None, + add_build_tags: Optional[List[str]] = None, +) -> Tuple[str, str]: + with tempfile.TemporaryDirectory() as tmpdirname: + containerfile_path = join(tmpdirname, "Dockerfile") + iidfile = join(tmpdirname, str(uuid4())) + with open(containerfile_path, "w", encoding="utf8") as containerfile_f: + _logger.debug( + "Writing containerfile to %s: %s", + containerfile_path, + containerfile, + ) + containerfile_f.write(containerfile) + + cmd = container_runtime.build_command + if "podman" in container_runtime.runner_binary: + if image_format is not None: + cmd += ("--format", str(image_format)) + else: + if ( + not container_runtime.supports_healthcheck_inherit_from_base + ): + warnings.warn( + UserWarning( + "Runtime does not support inheriting HEALTHCHECK " + "from base images, image format auto-detection " + "will *not* work!" + ) + ) + + # if the parent image has a healthcheck defined, then we + # have to use the docker image format, so that the + # healthcheck is in newly build image as well + elif parent_image_id and ( + "" + != check_output( + [ + container_runtime.runner_binary, + "inspect", + "-f", + "{{.HealthCheck}}", + parent_image_id, + ] + ) + .decode() + .strip() + ): + cmd += ("--format", str(ImageFormat.DOCKER)) + + cmd += extra_build_args or () + if add_build_tags: + cmd += functools.reduce( + operator.add, + (("-t", tag) for tag in add_build_tags), + ) + + cmd += (f"--iidfile={iidfile}", "-f", containerfile_path, str(rootdir)) + + _logger.debug("Building image via: %s", cmd) + check_output(cmd) + + container_image_id = container_runtime.get_image_id_from_iidfile( + iidfile + ) + + internal_build_tag = f"pytest_container:{container_image_id}" + + check_output( + ( + container_runtime.runner_binary, + "tag", + container_image_id, + internal_build_tag, + ) + ) + + _logger.debug( + "Successfully build the container image %s and tagged it as %s", + container_image_id, + internal_build_tag, + ) + + return container_image_id, internal_build_tag + + @dataclass(unsafe_hash=True) class DerivedContainer(ContainerBase, ContainerBaseABC): """Class for storing information about the Container Image under test, that @@ -765,8 +855,6 @@ def prepare_container( container_runtime, rootdir, extra_build_args ) - runtime = get_selected_runtime() - # do not build containers without a containerfile and where no build # tags are added if not self.containerfile and not self.add_build_tags: @@ -777,98 +865,32 @@ def prepare_container( self.container_id, self.url = base.container_id, base.url return - with tempfile.TemporaryDirectory() as tmpdirname: - containerfile_path = join(tmpdirname, "Dockerfile") - iidfile = join(tmpdirname, str(uuid4())) - with open(containerfile_path, "w") as containerfile: - from_id = ( - self.base - if isinstance(self.base, str) - else (getattr(self.base, "url") or self.base._build_tag) - ) - assert from_id - containerfile_contents = f"""FROM {from_id} + if isinstance(self.base, str): + from_id = self.base + else: + from_id = getattr(self.base, "url") or self.base._build_tag + assert from_id and isinstance(from_id, str) + + containerfile_contents = f"""FROM {from_id} {self.containerfile} """ - _logger.debug( - "Writing containerfile to %s: %s", - containerfile_path, - containerfile_contents, - ) - containerfile.write(containerfile_contents) - - cmd = runtime.build_command - if "podman" in runtime.runner_binary: - if self.image_format is not None: - cmd += ["--format", str(self.image_format)] - else: - if not runtime.supports_healthcheck_inherit_from_base: - warnings.warn( - UserWarning( - "Runtime does not support inheriting HEALTHCHECK " - "from base images, image format auto-detection " - "will *not* work!" - ) - ) - - # if the parent image has a healthcheck defined, then we - # have to use the docker image format, so that the - # healthcheck is in newly build image as well - elif ( - "" - != check_output( - [ - runtime.runner_binary, - "inspect", - "-f", - "{{.HealthCheck}}", - from_id, - ] - ) - .decode() - .strip() - ): - cmd += ["--format", str(ImageFormat.DOCKER)] - - cmd += ( - (extra_build_args or []) - + ( - functools.reduce( - operator.add, - (["-t", tag] for tag in self.add_build_tags), - ) - if self.add_build_tags - else [] - ) - + [ - f"--iidfile={iidfile}", - "-f", - containerfile_path, - str(rootdir), - ] - ) + self.container_id, internal_build_tag = _run_container_build( + container_runtime, + rootdir, + containerfile_contents, + parent_image_id=from_id, + extra_build_args=( + tuple(*extra_build_args) if extra_build_args else () + ), + image_format=self.image_format, + add_build_tags=self.add_build_tags, + ) - _logger.debug("Building image via: %s", cmd) - check_output(cmd) + assert self._build_tag == internal_build_tag - self.container_id = runtime.get_image_id_from_iidfile(iidfile) - assert self._build_tag.startswith("pytest_container:") - check_output( - ( - runtime.runner_binary, - "tag", - self.container_id, - self._build_tag, - ) - ) - _logger.debug( - "Successfully build the container image %s and tagged it as %s", - self.container_id, - self._build_tag, - ) @dataclass(frozen=True) From 7d11e9d0622f862c19d21491b259d93b067259c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20=C4=8Cerm=C3=A1k?= Date: Wed, 21 Aug 2024 12:10:07 +0200 Subject: [PATCH 3/6] Add MultiStageContainer class --- CHANGELOG.rst | 16 ++- pytest_container/__init__.py | 2 + pytest_container/container.py | 171 ++++++++++++++++++++++++----- pytest_container/pod.py | 3 +- source/api.rst | 2 + source/fixtures.rst | 2 +- source/usage.rst | 76 ++++++++++++- tests/test_container_build.py | 85 -------------- tests/test_multistage_container.py | 170 ++++++++++++++++++++++++++++ 9 files changed, 404 insertions(+), 123 deletions(-) create mode 100644 tests/test_multistage_container.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b082e52c..f11a13ba 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,6 +11,10 @@ Breaking changes: Improvements and new features: +- Add the class :py:class:`~pytest_container.container.MultiStageContainer` as a + replacement of :py:class:`~pytest_container.build.MultiStageBuild` to handle + container images built from a :file:`Containerfile` with multiple stages + - Add the function :py:func:`~pytest_container.container.ContainerData.read_container_logs` to get access to the logs of the running container @@ -73,15 +77,16 @@ Internal changes: Breaking changes: - add the parameter ``container_runtime`` to - :py:func:`~pytest_container.container.ContainerBaseABC.prepare_container` and - :py:func:`~pytest_container.build.MultiStageBuild.prepare_build`. + ``ContainerBaseABC.prepare_container`` (now called + :py:func:`~pytest_container.container._ContainerPrepareABC.prepare_container`) + and :py:func:`~pytest_container.build.MultiStageBuild.prepare_build`. - deprecate the function ``pytest_container.container_from_pytest_param``, please use :py:func:`~pytest_container.container.container_and_marks_from_pytest_param` instead. -- :py:func:`~pytest_container.container.ContainerBaseABC.get_base` no longer +- :py:func:`~pytest_container.container._ContainerBaseABC.get_base` no longer returns the recursive base but the immediate base. @@ -129,7 +134,7 @@ Breaking changes: Improvements and new features: -- Add :py:attr:`~pytest_container.container.ContainerBaseABC.baseurl` property +- Add :py:attr:`~pytest_container.container._ContainerBaseABC.baseurl` property to get the registry url of the container on which any currently existing container is based on. @@ -227,7 +232,8 @@ Improvements and new features: parametrize this test run. - Add support to add tags to container images via - :py:attr:`~pytest_container.container.DerivedContainer.add_build_tags`. + ``DerivedContainer.add_build_tags`` (is now called + :py:attr:`~pytest_container.container._ContainerForBuild.add_build_tags`) - Lock container preparation so that only a single process is pulling & building a container image. diff --git a/pytest_container/__init__.py b/pytest_container/__init__.py index 1f660edb..997259ec 100644 --- a/pytest_container/__init__.py +++ b/pytest_container/__init__.py @@ -10,6 +10,7 @@ "container_from_pytest_param", "container_to_pytest_param", "DerivedContainer", + "MultiStageContainer", "add_extra_run_and_build_args_options", "add_logging_level_options", "auto_container_parametrize", @@ -31,6 +32,7 @@ from .container import container_from_pytest_param from .container import container_to_pytest_param from .container import DerivedContainer +from .container import MultiStageContainer from .helpers import add_extra_run_and_build_args_options from .helpers import add_logging_level_options from .helpers import auto_container_parametrize diff --git a/pytest_container/container.py b/pytest_container/container.py index 3bdd2625..ba113c28 100644 --- a/pytest_container/container.py +++ b/pytest_container/container.py @@ -25,6 +25,7 @@ from os.path import isabs from os.path import join from pathlib import Path +from string import Template from subprocess import call from subprocess import check_output from types import TracebackType @@ -616,7 +617,7 @@ def filelock_filename(self) -> str: if isinstance(value, list): all_elements.append("".join([str(elem) for elem in value])) elif isinstance(value, dict): - all_elements.append("".join(value.values())) + all_elements.append("".join(str(v) for v in value.values())) else: all_elements.append(str(value)) @@ -627,9 +628,9 @@ def filelock_filename(self) -> str: return f"{sha3_256((''.join(all_elements)).encode()).hexdigest()}.lock" -class ContainerBaseABC(ABC): +class _ContainerPrepareABC(ABC): """Abstract base class defining the methods that must be implemented by the - classes fed to the ``*container*`` fixtures. + classes fed to the ``container_image`` fixture. """ @@ -642,6 +643,13 @@ def prepare_container( ) -> None: """Prepares the container so that it can be launched.""" + +class _ContainerBaseABC(_ContainerPrepareABC): + """Abstract base class defining the methods that must be implemented by the + classes fed to the ``*container*`` fixtures. + + """ + @abstractmethod def get_base(self) -> "Union[Container, DerivedContainer]": """Returns the Base of this Container Image. If the container has no @@ -659,7 +667,7 @@ def baseurl(self) -> Optional[str]: @dataclass(unsafe_hash=True) -class Container(ContainerBase, ContainerBaseABC): +class Container(ContainerBase, _ContainerBaseABC): """This class stores information about the Container Image under test.""" def pull_container(self, container_runtime: OciRuntimeBase) -> None: @@ -789,20 +797,12 @@ def _run_container_build( @dataclass(unsafe_hash=True) -class DerivedContainer(ContainerBase, ContainerBaseABC): - """Class for storing information about the Container Image under test, that - is build from a :file:`Containerfile`/:file:`Dockerfile` from a different - image (can be any image from a registry or an instance of - :py:class:`Container` or :py:class:`DerivedContainer`). +class _ContainerForBuild(ContainerBase): + """Intermediate class for adding properties to :py:class:`DerivedContainer` + and :py:class:`MultiStageContainer`. """ - base: Union[Container, "DerivedContainer", str] = "" - - #: The :file:`Containerfile` that is used to build this container derived - #: from :py:attr:`base`. - containerfile: str = "" - #: An optional image format when building images with :command:`buildah`. It #: is ignored when the container runtime is :command:`docker`. #: The ``oci`` image format is used by default. If the image format is @@ -816,6 +816,22 @@ class DerivedContainer(ContainerBase, ContainerBaseABC): #: has been built add_build_tags: List[str] = field(default_factory=list) + +@dataclass(unsafe_hash=True) +class DerivedContainer(_ContainerForBuild, _ContainerBaseABC): + """Class for storing information about the Container Image under test, that + is build from a :file:`Containerfile`/:file:`Dockerfile` from a different + image (can be any image from a registry or an instance of + :py:class:`Container` or :py:class:`DerivedContainer`). + + """ + + base: Union[Container, "DerivedContainer", str] = "" + + #: The :file:`Containerfile` that is used to build this container derived + #: from :py:attr:`base`. + containerfile: str = "" + def __post_init__(self) -> None: super().__post_init__() if not self.base: @@ -889,8 +905,88 @@ def prepare_container( assert self._build_tag == internal_build_tag +@dataclass +class MultiStageContainer(_ContainerForBuild, _ContainerPrepareABC): + """Class representing a container built from a :file:`Containerfile` + containing multiple stages. The :py:attr:`MultiStageContainer.containerfile` + is templated using the builtin :py:class:`string.Template`, where container + image IDs are inserted from the containers in + :py:attr:`MultiStageContainer.containers` after these have been + built/pulled. + """ + #: :file:`Containerfile` to built the container. If any stages require + #: images that are defined using a :py:class:`DerivedContainer` or a + #: :py:class:`Container`, then insert their ids as a template name and + #: provide that name and the class instance as the key & value into + #: :py:attr:`containers`. + containerfile: str = "" + + #: Dictionary of container stages that are used to build the final + #: image. The keys are the template names used in :py:attr:`containerfile` + #: and will be replaced with the container image ids of the respective values. + containers: Dict[str, Union[Container, DerivedContainer, str]] = field( + default_factory=dict + ) + + #: Optional stage of the multistage container build that should be built. + #: The last stage is built by default. + target_stage: str = "" + + def prepare_container( + self, + container_runtime: OciRuntimeBase, + rootdir: Path, + extra_build_args: Optional[List[str]], + ) -> None: + """Builds all intermediate containers and then builds the final + container up to :py:attr:`target_stage` or to the last stage. + + """ + + template_kwargs: Dict[str, str] = {} + + for name, ctr in self.containers.items(): + if isinstance(ctr, str): + warnings.warn( + UserWarning( + "Putting container URLs or scratch into the containers " + "dictionary is not required, just add them to the " + "containerfile directly." + ) + ) + + if ctr == "scratch": + template_kwargs[name] = ctr + else: + cont_from_url = Container(url=ctr) + cont_from_url.prepare_container( + container_runtime, rootdir, extra_build_args + ) + template_kwargs[name] = cont_from_url._build_tag + else: + ctr.prepare_container( + container_runtime, rootdir, extra_build_args + ) + template_kwargs[name] = ctr._build_tag + + ctrfile = Template(self.containerfile).substitute(**template_kwargs) + + build_args = tuple(*extra_build_args) if extra_build_args else () + if self.target_stage: + build_args += ("--target", self.target_stage) + + self.container_id, internal_tag = _run_container_build( + container_runtime, + rootdir, + ctrfile, + None, + build_args, + self.image_format, + self.add_build_tags, + ) + assert self._build_tag == internal_tag @dataclass(frozen=True) @@ -909,7 +1005,7 @@ class ContainerData: #: the testinfra connection to the running container connection: Any #: the container data class that has been used in this test - container: Union[Container, DerivedContainer] + container: Union[Container, DerivedContainer, MultiStageContainer] #: any ports that are exposed by this container forwarded_ports: List[PortForwarding] @@ -956,50 +1052,65 @@ def container_to_pytest_param( def container_and_marks_from_pytest_param( ctr_or_param: Container, ) -> Tuple[Container, Literal[None]]: - ... + ... # pragma: no cover @overload def container_and_marks_from_pytest_param( ctr_or_param: DerivedContainer, ) -> Tuple[DerivedContainer, Literal[None]]: - ... + ... # pragma: no cover + + +@overload +def container_and_marks_from_pytest_param( + ctr_or_param: MultiStageContainer, +) -> Tuple[MultiStageContainer, Literal[None]]: + ... # pragma: no cover @overload def container_and_marks_from_pytest_param( ctr_or_param: _pytest.mark.ParameterSet, ) -> Tuple[ - Union[Container, DerivedContainer], + Union[Container, DerivedContainer, MultiStageContainer], Optional[Collection[Union[_pytest.mark.MarkDecorator, _pytest.mark.Mark]]], ]: - ... + ... # pragma: no cover def container_and_marks_from_pytest_param( ctr_or_param: Union[ - _pytest.mark.ParameterSet, Container, DerivedContainer + _pytest.mark.ParameterSet, + Container, + DerivedContainer, + MultiStageContainer, ], ) -> Tuple[ - Union[Container, DerivedContainer], + Union[Container, DerivedContainer, MultiStageContainer], Optional[Collection[Union[_pytest.mark.MarkDecorator, _pytest.mark.Mark]]], ]: - """Extracts the :py:class:`~pytest_container.container.Container` or - :py:class:`~pytest_container.container.DerivedContainer` and the + """Extracts the :py:class:`~pytest_container.container.Container`, + :py:class:`~pytest_container.container.DerivedContainer` or + :py:class:`~pytest_container.container.MultiStageContainer` and the corresponding marks from a `pytest.param `_ and returns both. If ``param`` is either a :py:class:`~pytest_container.container.Container` - or a :py:class:`~pytest_container.container.DerivedContainer`, then param is - returned directly and the second return value is ``None``. + or a :py:class:`~pytest_container.container.DerivedContainer` or a + :py:class:`~pytest_container.container.MultiStageContainer`, then ``param`` + is returned directly and the second return value is ``None``. """ - if isinstance(ctr_or_param, (Container, DerivedContainer)): + if isinstance( + ctr_or_param, (Container, DerivedContainer, MultiStageContainer) + ): return ctr_or_param, None if len(ctr_or_param.values) > 0 and isinstance( - ctr_or_param.values[0], (Container, DerivedContainer) + ctr_or_param.values[0], + (Container, DerivedContainer, MultiStageContainer), ): return ctr_or_param.values[0], ctr_or_param.marks @@ -1043,7 +1154,7 @@ class ContainerLauncher: """ #: The container that will be launched - container: Union[Container, DerivedContainer] + container: Union[Container, DerivedContainer, MultiStageContainer] #: The container runtime via which the container will be launched container_runtime: OciRuntimeBase @@ -1073,7 +1184,7 @@ class ContainerLauncher: @staticmethod def from_pytestconfig( - container: Union[Container, DerivedContainer], + container: Union[Container, DerivedContainer, MultiStageContainer], container_runtime: OciRuntimeBase, pytestconfig: pytest.Config, container_name: str = "", diff --git a/pytest_container/pod.py b/pytest_container/pod.py index 4ed98879..9654f380 100644 --- a/pytest_container/pod.py +++ b/pytest_container/pod.py @@ -19,6 +19,7 @@ from pytest_container.container import create_host_port_port_forward from pytest_container.container import DerivedContainer from pytest_container.container import lock_host_port_search +from pytest_container.container import MultiStageContainer from pytest_container.helpers import get_extra_build_args from pytest_container.helpers import get_extra_pod_create_args from pytest_container.helpers import get_extra_run_args @@ -39,7 +40,7 @@ class Pod: """ #: containers belonging to the pod - containers: List[Union[DerivedContainer, Container]] + containers: List[Union[MultiStageContainer, DerivedContainer, Container]] #: ports exposed by the pod forwarded_ports: List[PortForwarding] = field(default_factory=list) diff --git a/source/api.rst b/source/api.rst index 9019673a..e01331e7 100644 --- a/source/api.rst +++ b/source/api.rst @@ -8,6 +8,8 @@ The container module .. automodule:: pytest_container.container :members: :undoc-members: + :private-members: + :show-inheritance: The pod module diff --git a/source/fixtures.rst b/source/fixtures.rst index 60a2d395..b726ae9e 100644 --- a/source/fixtures.rst +++ b/source/fixtures.rst @@ -45,5 +45,5 @@ directive is only supported for docker images. While this is the default with :command:`docker`, :command:`buildah` will by default build images in the ``OCIv1`` format which does **not** support ``HEALTHCHECK``. To ensure that your created container includes the ``HEALTHCHECK``, set the attribute -:py:attr:`~pytest_container.container.DerivedContainer.image_format` to +:py:attr:`~pytest_container.container._ContainerForBuild.image_format` to :py:attr:`~pytest_container.container.ImageFormat.DOCKER`. diff --git a/source/usage.rst b/source/usage.rst index 651b9190..24d1d2dd 100644 --- a/source/usage.rst +++ b/source/usage.rst @@ -1,6 +1,80 @@ Usage Tips ========== + +Running multi-stage builds +-------------------------- + +``pytest_container`` has builtin support for testing containers that are built +as part of multi-stage builds. More specifically, it supports using containers +defined via the :py:class:`~pytest_container.container.Container` and +:py:class:`~pytest_container.container.DerivedContainer` classes. This is +achieved by providing a :file:`Containerfile` and inserting template strings +instead of the specific container URIs or IDs, e.g.: + +.. code-block:: python + + # the "base" of the final stage + FINAL_BASE = DerivedContainer(...) + + MULTI_STAGE_CTR = MultiStageContainer( + containers={ + "final": FINAL_BASE, + "base": "registry.opensuse.org/opensuse/leap:latest", + }, + containerfile="""FROM $base as base + WORKDIR /src/ + RUN # build something here + + FROM $final as deploy + COPY --from=base /src/binary /usr/bin/binary + """, + ) + + +Such a defined container can be used in a similar fashion to the +:py:class:`~pytest_container.container.Container` and +:py:class:`~pytest_container.container.DerivedContainer` classes in tests as +follows: + +.. code-block:: python + + @pytest.mark.parametrize("container", [MULTI_STAGE_CTR], indirect=True) + def test_multistage_binary_in_final_stage(container: ContainerData): + binary = container.connection.file("/usr/bin/binary") + assert binary.exists and binary.is_executable + + +It is also possible to define which stage of a multi-stage container should be +built. By default the last stage is built. To built a different stage, set +:py:attr:`~pytest_container.container.MultiStageContainer.target_stage` to the +stage to build to (this is equivalent to the ``--target`` setting supplied to +:command:`buildah bud` or :command:`docker build`): + +.. code-block:: python + + MULTI_STAGE_CTR_W_STAGE = MultiStageContainer( + containers={ + "final": FINAL_BASE, + "base": "registry.opensuse.org/opensuse/leap:latest", + }, + containerfile="""FROM $base as base + WORKDIR /src/ + RUN # build something here + + FROM $final as deploy + COPY --from=base /src/binary /usr/bin/binary + """, + target_stage="base" + ) + + @pytest.mark.parametrize("container", [MULTI_STAGE_CTR_W_STAGE], indirect=True) + def test_multistage_binary_in_first_stage(container: ContainerData): + binary = container.connection.file("/src/binary") + assert binary.exists and binary.is_executable + + + Adding global build, run or pod create arguments ------------------------------------------------ @@ -8,7 +82,7 @@ Sometimes it is necessary to customize the build, run or pod create parameters of the container runtime globally, e.g. to use the host's network with docker via ``--network=host``. -The :py:meth:`~pytest_container.container.ContainerBaseABC.prepare_container` +The :py:meth:`~pytest_container.container._ContainerPrepareABC.prepare_container` and :py:meth:`~pytest_container.container.ContainerBase.get_launch_cmd` methods support passing such additional arguments/flags, but this is rather cumbersome to use in practice. The ``*container*`` and ``pod*`` fixtures will therefore diff --git a/tests/test_container_build.py b/tests/test_container_build.py index 2cd74334..72aad101 100644 --- a/tests/test_container_build.py +++ b/tests/test_container_build.py @@ -6,14 +6,11 @@ from pytest import Config from pytest_container import Container from pytest_container import DerivedContainer -from pytest_container import get_extra_build_args -from pytest_container.build import MultiStageBuild from pytest_container.container import BindMount from pytest_container.container import ContainerData from pytest_container.container import ContainerLauncher from pytest_container.container import EntrypointSelection from pytest_container.inspect import PortForwarding -from pytest_container.runtime import LOCALHOST from pytest_container.runtime import OciRuntimeBase from .images import LEAP @@ -67,27 +64,6 @@ CONTAINER_IMAGES = [LEAP, LEAP_WITH_MAN, LEAP_WITH_MAN_AND_LUA] -MULTI_STAGE_BUILD = MultiStageBuild( - containers={ - "builder": LEAP_WITH_MAN, - "runner1": LEAP, - "runner2": "docker.io/alpine", - }, - containerfile_template=r"""FROM $builder as builder -WORKDIR /src -RUN echo $$'#!/bin/sh \n\ -echo "foobar"' > test.sh && chmod +x test.sh - -FROM $runner1 as runner1 -WORKDIR /bin -COPY --from=builder /src/test.sh . -ENTRYPOINT ["/bin/test.sh"] - -FROM $runner2 as runner2 -WORKDIR /bin -COPY --from=builder /src/test.sh . -""", -) # This container would just stop if we would launch it with -d and use the # default entrypoint. If we set the entrypoint to bash, then it should stay up. @@ -258,67 +234,6 @@ def test_derived_container_respects_launch_args( assert int(container.connection.check_output("id -u").strip()) == 0 -def test_multistage_containerfile() -> None: - assert "FROM docker.io/alpine" in MULTI_STAGE_BUILD.containerfile - - -def test_multistage_build( - tmp_path: Path, pytestconfig: Config, container_runtime: OciRuntimeBase -): - MULTI_STAGE_BUILD.build( - tmp_path, - pytestconfig.rootpath, - container_runtime, - extra_build_args=get_extra_build_args(pytestconfig), - ) - - -def test_multistage_build_target( - tmp_path: Path, pytestconfig: Config, container_runtime: OciRuntimeBase -): - first_target = MULTI_STAGE_BUILD.build( - tmp_path, - pytestconfig.rootpath, - container_runtime, - "runner1", - extra_build_args=get_extra_build_args(pytestconfig), - ) - assert ( - LOCALHOST.check_output( - f"{container_runtime.runner_binary} run --rm {first_target}", - ).strip() - == "foobar" - ) - - second_target = MULTI_STAGE_BUILD.build( - tmp_path, - pytestconfig, - container_runtime, - "runner2", - extra_build_args=get_extra_build_args(pytestconfig), - ) - - assert first_target != second_target - assert ( - LOCALHOST.check_output( - f"{container_runtime.runner_binary} run --rm {second_target} /bin/test.sh", - ).strip() - == "foobar" - ) - - for (distro, target) in ( - ("Leap", first_target), - ("Alpine", second_target), - ): - assert ( - distro - in LOCALHOST.check_output( - f"{container_runtime.runner_binary} run --rm --entrypoint= {target} " - "cat /etc/os-release", - ).strip() - ) - - LEAP_THAT_ECHOES_STUFF = DerivedContainer( base=LEAP, containerfile="""CMD ["echo", "foobar"]""" ) diff --git a/tests/test_multistage_container.py b/tests/test_multistage_container.py new file mode 100644 index 00000000..82c53a6d --- /dev/null +++ b/tests/test_multistage_container.py @@ -0,0 +1,170 @@ +# pylint: disable=missing-function-docstring,missing-module-docstring +from pathlib import Path + +import pytest +from pytest_container import get_extra_build_args +from pytest_container import MultiStageBuild +from pytest_container import MultiStageContainer +from pytest_container import OciRuntimeBase +from pytest_container.container import ContainerData +from pytest_container.container import EntrypointSelection +from pytest_container.runtime import LOCALHOST + +from tests.images import LEAP +from tests.images import LEAP_URL +from tests.images import LEAP_WITH_MAN + + +_MULTISTAGE_CTR_FILE = r"""FROM $builder as builder +WORKDIR /src +RUN echo $$'#!/bin/sh \n\ +echo "foobar"' > test.sh && chmod +x test.sh + +FROM $runner1 as runner1 +WORKDIR /bin +COPY --from=builder /src/test.sh . +ENTRYPOINT ["/bin/test.sh"] + +FROM $runner2 as runner2 +WORKDIR /bin +COPY --from=builder /src/test.sh . +""" + +_MULTISTAGE_CTRS = { + "builder": LEAP_WITH_MAN, + "runner1": LEAP, + "runner2": "docker.io/alpine", +} + +MULTI_STAGE_BUILD = MultiStageBuild( + containers=_MULTISTAGE_CTRS, + containerfile_template=_MULTISTAGE_CTR_FILE, +) + +MULTI_STAGE_CTR = MultiStageContainer( + containers=_MULTISTAGE_CTRS, + containerfile=_MULTISTAGE_CTR_FILE, +) + +MULTI_STAGE_CTR_STAGE_1 = MultiStageContainer( + containers=_MULTISTAGE_CTRS, + containerfile=_MULTISTAGE_CTR_FILE, + target_stage="runner1", + entry_point=EntrypointSelection.BASH, +) + +MULTI_STAGE_CTR_STAGE_2 = MultiStageContainer( + containers=_MULTISTAGE_CTRS, + containerfile=_MULTISTAGE_CTR_FILE, + target_stage="runner2", +) + + +def test_multistage_containerfile() -> None: + assert "FROM docker.io/alpine" in MULTI_STAGE_BUILD.containerfile + + +def test_multistage_build( + tmp_path: Path, + pytestconfig: pytest.Config, + container_runtime: OciRuntimeBase, +): + MULTI_STAGE_BUILD.build( + tmp_path, + pytestconfig.rootpath, + container_runtime, + extra_build_args=get_extra_build_args(pytestconfig), + ) + + +def test_multistage_build_target( + tmp_path: Path, + pytestconfig: pytest.Config, + container_runtime: OciRuntimeBase, +): + first_target = MULTI_STAGE_BUILD.build( + tmp_path, + pytestconfig.rootpath, + container_runtime, + "runner1", + extra_build_args=get_extra_build_args(pytestconfig), + ) + assert ( + LOCALHOST.check_output( + f"{container_runtime.runner_binary} run --rm {first_target}", + ).strip() + == "foobar" + ) + + second_target = MULTI_STAGE_BUILD.build( + tmp_path, + pytestconfig, + container_runtime, + "runner2", + extra_build_args=get_extra_build_args(pytestconfig), + ) + + assert first_target != second_target + assert ( + LOCALHOST.check_output( + f"{container_runtime.runner_binary} run --rm {second_target} /bin/test.sh", + ).strip() + == "foobar" + ) + + for (distro, target) in ( + ("Leap", first_target), + ("Alpine", second_target), + ): + assert ( + distro + in LOCALHOST.check_output( + f"{container_runtime.runner_binary} run --rm --entrypoint= {target} " + "cat /etc/os-release", + ).strip() + ) + + +@pytest.mark.parametrize("container", [MULTI_STAGE_CTR], indirect=True) +def test_multistage_container_without_stage(container: ContainerData) -> None: + assert container.connection.file("/bin/test.sh").exists + assert ( + "Alpine" in container.connection.file("/etc/os-release").content_string + ) + + +@pytest.mark.parametrize("container", [MULTI_STAGE_CTR_STAGE_2], indirect=True) +def test_multistage_container_with_runner2_stage( + container: ContainerData, +) -> None: + assert container.connection.file("/bin/test.sh").exists + assert ( + "Alpine" in container.connection.file("/etc/os-release").content_string + ) + + +@pytest.mark.parametrize("container", [MULTI_STAGE_CTR_STAGE_1], indirect=True) +def test_multistage_container_with_runner1_stage( + container: ContainerData, +) -> None: + assert container.connection.file("/bin/test.sh").exists + assert ( + "Leap" in container.connection.file("/etc/os-release").content_string + ) + + +@pytest.mark.parametrize( + "container", + [ + MultiStageContainer( + containerfile="""FROM $nothing as nothing +FROM $builder as builder +RUN zypper -n in busybox +""", + containers={"nothing": "scratch", "builder": LEAP_URL}, + ) + ], + indirect=True, +) +def test_multistage_does_not_pull_scratch(container: ContainerData) -> None: + container.connection.check_output("true") From 48b4a2a665c39335214c3cc91e75566520b03249 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20=C4=8Cerm=C3=A1k?= Date: Thu, 22 Aug 2024 19:53:49 +0200 Subject: [PATCH 4/6] deprecate MultiStageBuild --- CHANGELOG.rst | 3 +++ pytest_container/build.py | 26 ++++++++++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f11a13ba..46fc2d2a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -3,6 +3,9 @@ Next Release Breaking changes: +- deprecate :py:class:`~pytest_container.build.MultiStageBuild` in favor + :py:class:`~pytest_container.container.MultiStageContainer` + - change type of ``OciRuntimeBase.build_command`` from ``List[str]`` to ``Tuple[str, ...]`` diff --git a/pytest_container/build.py b/pytest_container/build.py index 22d25140..759d7529 100644 --- a/pytest_container/build.py +++ b/pytest_container/build.py @@ -3,6 +3,7 @@ builds via :py:class:`MultiStageBuild`. """ +import sys import tempfile from dataclasses import dataclass from os.path import basename @@ -10,6 +11,7 @@ from pathlib import Path from string import Template from subprocess import check_output +from typing import cast from typing import Dict from typing import List from typing import Optional @@ -18,6 +20,7 @@ from _pytest.config import Config from _pytest.mark.structures import ParameterSet +from deprecation import deprecated from pytest_container.container import Container from pytest_container.container import container_and_marks_from_pytest_param from pytest_container.container import DerivedContainer @@ -25,6 +28,11 @@ from pytest_container.runtime import OciRuntimeBase from pytest_container.runtime import ToParamMixin +if sys.version_info >= (3, 8): + from importlib import metadata +else: + import importlib_metadata as metadata + @dataclass(frozen=True) class GitRepositoryBuild(ToParamMixin): @@ -83,6 +91,14 @@ def test_command(self) -> str: return cd_cmd +_deprecated_multi_stage_build_kwargs = { + "deprecated_in": "0.5.0", + "removed_in": "0.6.0", + "current_version": metadata.version("pytest_container"), + "details": "use MultiStageContainer instead", +} + + @dataclass class MultiStageBuild: """Helper class to perform multi-stage container builds using the @@ -165,6 +181,7 @@ def containerfile(self) -> str: } ) + @deprecated(**_deprecated_multi_stage_build_kwargs) # type: ignore def prepare_build( self, tmp_path: Path, @@ -197,6 +214,7 @@ def prepare_build( containerfile.write(self.containerfile) @staticmethod + @deprecated(**_deprecated_multi_stage_build_kwargs) # type: ignore def run_build_step( tmp_path: Path, runtime: OciRuntimeBase, @@ -233,6 +251,7 @@ def run_build_step( check_output(cmd) return runtime.get_image_id_from_iidfile(iidfile) + @deprecated(**_deprecated_multi_stage_build_kwargs) # type: ignore def build( self, tmp_path: Path, @@ -278,6 +297,9 @@ def build( root, extra_build_args, ) - return MultiStageBuild.run_build_step( - tmp_path, runtime, target, extra_build_args + return cast( + str, + MultiStageBuild.run_build_step( + tmp_path, runtime, target, extra_build_args + ), ) From c9c4fd7614df6d999de8210a1e88235b01163545 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 5/6] Add new fixture container_image --- CHANGELOG.rst | 12 ++-- pytest_container/container.py | 103 ++++++++++++++++++++++++++++++---- pytest_container/plugin.py | 29 ++++++++++ tests/test_container_run.py | 39 +++++++++++++ 4 files changed, 167 insertions(+), 16 deletions(-) create mode 100644 tests/test_container_run.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 46fc2d2a..cbbf3d57 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -227,12 +227,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 ba113c28..40507ec5 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 @@ -1206,11 +1263,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 @@ -1252,6 +1311,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 @@ -1291,6 +1357,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 186597cd..495a72b1 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 00000000..205b0109 --- /dev/null +++ b/tests/test_container_run.py @@ -0,0 +1,39 @@ +# 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" + ) From 0966fc0626c5e176d908f276f41299f5892a64f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20=C4=8Cerm=C3=A1k?= Date: Tue, 10 Sep 2024 17:01:11 +0200 Subject: [PATCH 6/6] Add breaking test case for volumes with the container_image fixture --- tests/test_container_run.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_container_run.py b/tests/test_container_run.py index 205b0109..25abed29 100644 --- a/tests/test_container_run.py +++ b/tests/test_container_run.py @@ -1,9 +1,12 @@ # pylint: disable=missing-function-docstring,missing-module-docstring import pytest from pytest_container.container import ContainerImageData +from pytest_container.container import ContainerLauncher from pytest_container.container import DerivedContainer +from pytest_container.runtime import OciRuntimeBase from tests.images import LEAP +from tests.test_volumes import LEAP_WITH_BIND_MOUNT_AND_VOLUME @pytest.mark.parametrize("container_image", [LEAP], indirect=True) @@ -37,3 +40,26 @@ def test_entrypoint_respected_in_run( assert "foobar" in host.check_output( f"{container_image.run_command} foobar" ) + + +@pytest.mark.parametrize( + "container_image", [LEAP_WITH_BIND_MOUNT_AND_VOLUME], indirect=True +) +def test_volume_created_on_enter( + container_image: ContainerImageData, host +) -> None: + host.check_output(f"{container_image.run_command} stat /foo") + host.check_output(f"{container_image.run_command} stat /bar") + + +def test_volume_destroyed_on_exit( + host, pytestconfig: pytest.Config, container_runtime: OciRuntimeBase +) -> None: + with ContainerLauncher.from_pytestconfig( + LEAP_WITH_BIND_MOUNT_AND_VOLUME, container_runtime, pytestconfig + ) as launcher: + launcher.prepare_container_image() + + cid = launcher.container_image_data + host.check_output(f"{cid.run_command} stat /foo") + host.check_output(f"{cid.run_command} stat /bar")