From 54c88cf00ad7bb08eb7894c52bed7a9010fd7786 Mon Sep 17 00:00:00 2001 From: Roy Moore Date: Fri, 31 May 2024 21:51:57 +0300 Subject: [PATCH] feat(core): Image build (Dockerfile support) (#585) As part of the effort described, detailed and presented on #559 (Providing the implementation for #83 - Docker file support and more) This is the first PR (out of 4) that should provide all the groundwork to support image build. This would allow users to use custom images: ```python with DockerImage(path=".") as image: with DockerContainer(str(image)) as container: # Test something with/on custom image ``` Next in line is: `feat(core): Added SrvContainer` And later on: `feat(core): Added FastAPI module` `feat(core): Added AWS Lambda module` (all of the above can be overviewed on #559) --- core/README.rst | 14 +++ core/testcontainers/core/docker_client.py | 21 +++++ core/testcontainers/core/image.py | 88 +++++++++++++++++++ core/tests/conftest.py | 22 +++++ .../{ => image_fixtures/busybox}/Dockerfile | 0 core/tests/image_fixtures/sample/Dockerfile | 2 + core/tests/test_core.py | 33 +++++++ core/tests/test_docker_client.py | 10 +++ 8 files changed, 190 insertions(+) create mode 100644 core/testcontainers/core/image.py create mode 100644 core/tests/conftest.py rename core/tests/{ => image_fixtures/busybox}/Dockerfile (100%) create mode 100644 core/tests/image_fixtures/sample/Dockerfile diff --git a/core/README.rst b/core/README.rst index 2256bd20..bdc46db6 100644 --- a/core/README.rst +++ b/core/README.rst @@ -4,3 +4,17 @@ testcontainers-core :code:`testcontainers-core` is the core functionality for spinning up Docker containers in test environments. .. autoclass:: testcontainers.core.container.DockerContainer + +.. autoclass:: testcontainers.core.image.DockerImage + +Using `DockerContainer` and `DockerImage` directly: + +.. doctest:: + + >>> from testcontainers.core.container import DockerContainer + >>> from testcontainers.core.waiting_utils import wait_for_logs + >>> from testcontainers.core.image import DockerImage + + >>> with DockerImage(path="./core/tests/image_fixtures/sample/", tag="test-sample:latest") as image: + ... with DockerContainer(str(image)) as container: + ... delay = wait_for_logs(container, "Test Sample Image") diff --git a/core/testcontainers/core/docker_client.py b/core/testcontainers/core/docker_client.py index 485adb59..00534c3e 100644 --- a/core/testcontainers/core/docker_client.py +++ b/core/testcontainers/core/docker_client.py @@ -16,10 +16,12 @@ import os import urllib import urllib.parse +from collections.abc import Iterable from typing import Callable, Optional, TypeVar, Union import docker from docker.models.containers import Container, ContainerCollection +from docker.models.images import Image, ImageCollection from typing_extensions import ParamSpec from testcontainers.core.config import testcontainers_config as c @@ -40,6 +42,14 @@ def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T: return wrapper +def _wrapped_image_collection(function: Callable[_P, _T]) -> Callable[_P, _T]: + @ft.wraps(ImageCollection.build) + def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T: + return function(*args, **kwargs) + + return wrapper + + class DockerClient: """ Thin wrapper around :class:`docker.DockerClient` for a more functional interface. @@ -94,6 +104,17 @@ def run( ) return container + @_wrapped_image_collection + def build(self, path: str, tag: str, rm: bool = True, **kwargs) -> tuple[Image, Iterable[dict]]: + """ + Build a Docker image from a directory containing the Dockerfile. + + :return: A tuple containing the image object and the build logs. + """ + image_object, image_logs = self.client.images.build(path=path, tag=tag, rm=rm, **kwargs) + + return image_object, image_logs + def find_host_network(self) -> Optional[str]: """ Try to find the docker host network. diff --git a/core/testcontainers/core/image.py b/core/testcontainers/core/image.py new file mode 100644 index 00000000..399200bf --- /dev/null +++ b/core/testcontainers/core/image.py @@ -0,0 +1,88 @@ +from typing import TYPE_CHECKING, Optional + +from typing_extensions import Self + +from testcontainers.core.docker_client import DockerClient +from testcontainers.core.utils import setup_logger + +if TYPE_CHECKING: + from docker.models.containers import Image + +logger = setup_logger(__name__) + + +class DockerImage: + """ + Basic image object to build Docker images. + + .. doctest:: + + >>> from testcontainers.core.image import DockerImage + + >>> with DockerImage(path="./core/tests/image_fixtures/sample/", tag="test-image") as image: + ... logs = image.get_logs() + + :param tag: Tag for the image to be built (default: None) + :param path: Path to the Dockerfile to build the image + """ + + def __init__( + self, + path: str, + docker_client_kw: Optional[dict] = None, + tag: Optional[str] = None, + clean_up: bool = True, + **kwargs, + ) -> None: + self.tag = tag + self.path = path + self.id = None + self._docker = DockerClient(**(docker_client_kw or {})) + self.clean_up = clean_up + self._kwargs = kwargs + + def build(self, **kwargs) -> Self: + logger.info(f"Building image from {self.path}") + docker_client = self.get_docker_client() + self._image, self._logs = docker_client.build(path=self.path, tag=self.tag, **kwargs) + logger.info(f"Built image {self.short_id} with tag {self.tag}") + return self + + @property + def short_id(self) -> str: + """ + The ID of the image truncated to 12 characters, without the ``sha256:`` prefix. + """ + if self._image.id.startswith("sha256:"): + return self._image.id.split(":")[1][:12] + return self._image.id[:12] + + def remove(self, force=True, noprune=False) -> None: + """ + Remove the image. + + :param force: Remove the image even if it is in use + :param noprune: Do not delete untagged parent images + """ + if self._image and self.clean_up: + logger.info(f"Removing image {self.short_id}") + self._image.remove(force=force, noprune=noprune) + self.get_docker_client().client.close() + + def __str__(self) -> str: + return f"{self.tag if self.tag else self.short_id}" + + def __enter__(self) -> Self: + return self.build() + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.remove() + + def get_wrapped_image(self) -> "Image": + return self._image + + def get_docker_client(self) -> DockerClient: + return self._docker + + def get_logs(self) -> list[dict]: + return list(self._logs) diff --git a/core/tests/conftest.py b/core/tests/conftest.py new file mode 100644 index 00000000..4f69565f --- /dev/null +++ b/core/tests/conftest.py @@ -0,0 +1,22 @@ +import pytest +from typing import Callable +from testcontainers.core.container import DockerClient + + +@pytest.fixture +def check_for_image() -> Callable[[str, bool], None]: + """Warp the check_for_image function in a fixture""" + + def _check_for_image(image_short_id: str, cleaned: bool) -> None: + """ + Validates if the image is present or not. + + :param image_short_id: The short id of the image + :param cleaned: True if the image should not be present, False otherwise + """ + client = DockerClient() + images = client.client.images.list() + found = any(image.short_id.endswith(image_short_id) for image in images) + assert found is not cleaned, f'Image {image_short_id} was {"found" if cleaned else "not found"}' + + return _check_for_image diff --git a/core/tests/Dockerfile b/core/tests/image_fixtures/busybox/Dockerfile similarity index 100% rename from core/tests/Dockerfile rename to core/tests/image_fixtures/busybox/Dockerfile diff --git a/core/tests/image_fixtures/sample/Dockerfile b/core/tests/image_fixtures/sample/Dockerfile new file mode 100644 index 00000000..d7d78603 --- /dev/null +++ b/core/tests/image_fixtures/sample/Dockerfile @@ -0,0 +1,2 @@ +FROM alpine:latest +CMD echo "Test Sample Image" diff --git a/core/tests/test_core.py b/core/tests/test_core.py index 4ebe9040..efac8262 100644 --- a/core/tests/test_core.py +++ b/core/tests/test_core.py @@ -1,6 +1,11 @@ import pytest +import tempfile +import random + +from typing import Optional from testcontainers.core.container import DockerContainer +from testcontainers.core.image import DockerImage from testcontainers.core.waiting_utils import wait_for_logs @@ -31,3 +36,31 @@ def test_can_get_logs(): assert isinstance(stdout, bytes) assert isinstance(stderr, bytes) assert stdout, "There should be something on stdout" + + +@pytest.mark.parametrize("test_cleanup", [True, False]) +@pytest.mark.parametrize("test_image_tag", [None, "test-image:latest"]) +def test_docker_image(test_image_tag: Optional[str], test_cleanup: bool, check_for_image): + with tempfile.TemporaryDirectory() as temp_directory: + # It's important to use a random string to avoid image caching + random_string = "Hello from Docker Image! " + str(random.randint(0, 1000)) + with open(f"{temp_directory}/Dockerfile", "w") as f: + f.write( + f""" + FROM alpine:latest + CMD echo "{random_string}" + """ + ) + with DockerImage(path=temp_directory, tag=test_image_tag, clean_up=test_cleanup) as image: + image_short_id = image.short_id + assert image.tag is test_image_tag, f"Expected {test_image_tag}, got {image.tag}" + assert image.short_id is not None, "Short ID should not be None" + logs = image.get_logs() + assert isinstance(logs, list), "Logs should be a list" + assert logs[0] == {"stream": "Step 1/2 : FROM alpine:latest"} + assert logs[3] == {"stream": f'Step 2/2 : CMD echo "{random_string}"'} + with DockerContainer(str(image)) as container: + assert container._container.image.short_id.endswith(image_short_id), "Image ID mismatch" + assert container.get_logs() == ((random_string + "\n").encode(), b""), "Container logs mismatch" + + check_for_image(image_short_id, test_cleanup) diff --git a/core/tests/test_docker_client.py b/core/tests/test_docker_client.py index cfd95be9..9234d306 100644 --- a/core/tests/test_docker_client.py +++ b/core/tests/test_docker_client.py @@ -9,6 +9,7 @@ from testcontainers.core.container import DockerContainer from testcontainers.core.docker_client import DockerClient from testcontainers.core.utils import parse_docker_auth_config +from testcontainers.core.image import DockerImage def test_docker_client_from_env(): @@ -54,3 +55,12 @@ def test_container_docker_client_kw(): DockerContainer(image="", docker_client_kw=test_kwargs) mock_docker.from_env.assert_called_with(**test_kwargs) + + +def test_image_docker_client_kw(): + test_kwargs = {"test_kw": "test_value"} + mock_docker = MagicMock(spec=docker) + with patch("testcontainers.core.docker_client.docker", mock_docker): + DockerImage(name="", path="", docker_client_kw=test_kwargs) + + mock_docker.from_env.assert_called_with(**test_kwargs)