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 21, 2024
1 parent a5859ab commit 11e002d
Show file tree
Hide file tree
Showing 6 changed files with 147 additions and 34 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
113 changes: 93 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,57 @@ 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):
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 +975,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 +1032,32 @@ def container_and_marks_from_pytest_param(
...


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


@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 +1071,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 +1122,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
53 changes: 45 additions & 8 deletions tests/test_container_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from pytest_container.container import ContainerData
from pytest_container.container import ContainerLauncher
from pytest_container.container import EntrypointSelection
from pytest_container.container import MultiStageContainer
from pytest_container.inspect import PortForwarding
from pytest_container.runtime import LOCALHOST
from pytest_container.runtime import OciRuntimeBase
Expand Down Expand Up @@ -63,13 +64,8 @@

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

_MULTISTAGE_CTR_FILE = r"""FROM $builder as builder
WORKDIR /src
RUN echo $$'#!/bin/sh \n\
echo "foobar"' > test.sh && chmod +x test.sh
Expand All @@ -82,9 +78,32 @@
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,
)


# 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.
CONTAINER_THAT_STOPS = DerivedContainer(
Expand Down Expand Up @@ -269,6 +288,24 @@ def test_multistage_build(
)


@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_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
)


def test_multistage_build_target(
tmp_path: Path, pytestconfig: Config, container_runtime: OciRuntimeBase
):
Expand Down

0 comments on commit 11e002d

Please sign in to comment.