Skip to content

Commit

Permalink
WIP: Add MultiStageContainer class
Browse files Browse the repository at this point in the history
  • Loading branch information
dcermak committed Aug 22, 2024
1 parent af9f035 commit 56eb88d
Show file tree
Hide file tree
Showing 8 changed files with 275 additions and 112 deletions.
8 changes: 5 additions & 3 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,9 @@ 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
Expand Down Expand Up @@ -225,7 +226,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.
Expand Down
2 changes: 2 additions & 0 deletions pytest_container/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down
116 changes: 96 additions & 20 deletions pytest_container/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -613,7 +614,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))

Expand All @@ -624,7 +625,7 @@ 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.
Expand All @@ -639,6 +640,8 @@ def prepare_container(
) -> None:
"""Prepares the container so that it can be launched."""


class ContainerBaseABC(ContainerPrepareABC):
@abstractmethod
def get_base(self) -> "Union[Container, DerivedContainer]":
"""Returns the Base of this Container Image. If the container has no
Expand Down Expand Up @@ -795,20 +798,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
Expand All @@ -822,6 +817,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:
Expand Down Expand Up @@ -895,8 +906,60 @@ def prepare_container(
assert self._build_tag == internal_build_tag


@dataclass
class MultiStageContainer(_ContainerForBuild, ContainerPrepareABC):
containerfile: str = ""

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:
"""Prepares the container so that it can be launched."""

template_kwargs: Dict[str, str] = {}

for name, ctr in self.containers.items():
if isinstance(ctr, str):
if ctr == "scratch":
template_kwargs[name] = ctr
else:
c = Container(url=ctr)
c.prepare_container(
container_runtime, rootdir, extra_build_args
)
template_kwargs[name] = c._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)
Expand All @@ -915,7 +978,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]

Expand Down Expand Up @@ -972,22 +1035,32 @@ def container_and_marks_from_pytest_param(
...


@overload
def container_and_marks_from_pytest_param(
ctr_or_param: MultiStageContainer,
) -> Tuple[MultiStageContainer, Literal[None]]:
...

Check warning on line 1042 in pytest_container/container.py

View check run for this annotation

Codecov / codecov/patch

pytest_container/container.py#L1042

Added line #L1042 was not covered by tests


@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]]],
]:
...


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
Expand All @@ -1001,11 +1074,14 @@ def container_and_marks_from_pytest_param(
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

Expand Down Expand Up @@ -1049,7 +1125,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
Expand Down
3 changes: 2 additions & 1 deletion pytest_container/pod.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,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.inspect import PortForwarding
from pytest_container.logging import _logger
from pytest_container.runtime import get_selected_runtime
Expand All @@ -35,7 +36,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)
Expand Down
2 changes: 1 addition & 1 deletion source/fixtures.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
2 changes: 1 addition & 1 deletion source/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,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
Expand Down
86 changes: 0 additions & 86 deletions tests/test_container_build.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
# pylint: disable=missing-function-docstring,missing-module-docstring
from pathlib import Path
from typing import Union

import pytest
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 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
Expand Down Expand Up @@ -63,27 +59,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.
Expand Down Expand Up @@ -254,67 +229,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"]"""
)
Expand Down
Loading

0 comments on commit 56eb88d

Please sign in to comment.