Skip to content

Commit

Permalink
Merge pull request #8 from filipsnastins/feature/build-docker-image-w…
Browse files Browse the repository at this point in the history
…ith-buildkit

Support Docker BuildKit
  • Loading branch information
filipsnastins authored Jul 29, 2023
2 parents 3bda11f + 94c98a5 commit c49e775
Show file tree
Hide file tree
Showing 6 changed files with 101 additions and 15 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,7 @@ by setting it in the shell before running `pytest`.
| `TESTCONTAINER_DOCKER_NETWORK` | Launch testcontainers in specified Docker network. Defaults to 'bridge'. Network must be created beforehand |
| `TOMODACHI_TESTCONTAINER_DOCKERFILE_PATH` | Override path to Dockerfile for building Tomodachi service image. Defaults to '.' |
| `<CONTAINER-NAME>_TESTCONTAINER_IMAGE_ID` | Override any supported Testcontainer Image ID. Defaults to `None`, `TOMODACHI_TESTCONTAINER_DOCKERFILE_PATH` takes precedence |
| `DOCKER_BUILDKIT` | Set `DOCKER_BUILDKIT=1` to use Docker BuildKit in `EphemeralDockerImage` |

## Changing default Docker network

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,5 @@ log_cli_level = "INFO"
env = [
"TESTCONTAINER_DOCKER_NETWORK=tomodachi-testcontainers",
"TOMODACHI_TESTCONTAINER_DOCKERFILE_PATH=examples/Dockerfile",
"DOCKER_BUILDKIT=1",
]
41 changes: 32 additions & 9 deletions src/tomodachi_testcontainers/containers/common.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os
import pathlib
import subprocess # nosec: B404
from pathlib import Path
from typing import Any, Dict, Iterator, Optional, Tuple, cast

import testcontainers.core.container
Expand Down Expand Up @@ -36,7 +37,7 @@ def get_container_gateway_ip(self) -> str:


class EphemeralDockerImage:
def __init__(self, dockerfile: pathlib.Path, docker_client_kw: Optional[Dict] = None) -> None:
def __init__(self, dockerfile: Path, docker_client_kw: Optional[Dict] = None) -> None:
self.client = DockerClient(**(docker_client_kw or {}))
self.image = self.build_image(dockerfile)

Expand All @@ -46,14 +47,41 @@ def __enter__(self) -> DockerImage:
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
self.remove_image()

def build_image(self, path: pathlib.Path) -> DockerImage:
def build_image(self, path: Path) -> DockerImage:
if path.is_dir():
dockerfile_dir = path
dockerfile_name = ""
else:
dockerfile_dir = path.parent
dockerfile_name = path.name

if os.getenv("DOCKER_BUILDKIT"):
return self._build_with_docker_buildkit(dockerfile_dir, dockerfile_name)

return self._build_with_docker_client(dockerfile_dir, dockerfile_name)

def remove_image(self) -> None:
self.client.client.images.remove(image=str(self.image.id))

def _build_with_docker_buildkit(self, dockerfile_dir: Path, dockerfile_name: str) -> DockerImage:
filepath = dockerfile_dir / dockerfile_name

cmd = ["docker", "build", "-q", "--rm=true"]
if filepath.is_file():
cmd.extend(["-f", str(filepath)])
cmd.append(str(dockerfile_dir))

result = subprocess.run( # nosec: B603
cmd,
shell=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
)
image_id = result.stdout.decode("utf-8").strip()
return cast(DockerImage, self.client.client.images.get(image_id))

def _build_with_docker_client(self, dockerfile_dir: Path, dockerfile_name: str) -> DockerImage:
image, _ = cast(
Tuple[DockerImage, Iterator],
self.client.client.images.build(
Expand All @@ -64,14 +92,9 @@ def build_image(self, path: pathlib.Path) -> DockerImage:
)
return image

def remove_image(self) -> None:
self.client.client.images.remove(image=str(self.image.id))


def get_docker_image(image_id: str, docker_client_kw: Optional[Dict] = None) -> Optional[DockerImage]:
def get_docker_image(image_id: str, docker_client_kw: Optional[Dict] = None) -> DockerImage:
client = DockerClient(**(docker_client_kw or {}))
if not image_id:
return None
try:
return cast(DockerImage, client.client.images.get(image_id))
except ImageNotFound:
Expand Down
8 changes: 4 additions & 4 deletions src/tomodachi_testcontainers/pytest/tomodachi_fixtures.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import os
import pathlib
from pathlib import Path
from typing import Generator

import pytest
Expand All @@ -10,9 +10,9 @@

@pytest.fixture(scope="session")
def tomodachi_image() -> Generator[DockerImage, None, None]:
if image := get_docker_image(os.environ.get("TOMODACHI_TESTCONTAINER_IMAGE_ID", "")):
yield image
if image_id := os.environ.get("TOMODACHI_TESTCONTAINER_IMAGE_ID"):
yield get_docker_image(image_id)
else:
dockerfile = pathlib.Path(os.environ.get("TOMODACHI_TESTCONTAINER_DOCKERFILE_PATH", "."))
dockerfile = Path(os.environ.get("TOMODACHI_TESTCONTAINER_DOCKERFILE_PATH", "."))
with EphemeralDockerImage(dockerfile) as image:
yield image
60 changes: 60 additions & 0 deletions tests/containers/test_common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import tempfile
from pathlib import Path
from typing import Generator

import pytest
from docker.errors import BuildError, ImageNotFound

from tomodachi_testcontainers.containers.common import EphemeralDockerImage, get_docker_image


class TestEphemeralDockerImage:
@pytest.fixture()
def dockerfile_hello_world(self, tmp_path: Path) -> Generator[Path, None, None]:
with tempfile.NamedTemporaryFile(mode="wt", encoding="utf-8", dir=tmp_path) as dockerfile:
dockerfile.writelines(
[
"FROM alpine:latest\n",
"RUN echo 'Hello, world!'\n",
]
)
dockerfile.flush()
yield Path(dockerfile.name)

@pytest.fixture()
def dockerfile_buildkit(self, tmp_path: Path) -> Generator[Path, None, None]:
with tempfile.NamedTemporaryFile(mode="wt", encoding="utf-8", dir=tmp_path) as dockerfile:
dockerfile.writelines(
[
"FROM alpine:latest\n",
# -- mount is a buildkit feature
"RUN --mount=type=secret,id=test,target=test echo 'Hello, World!'\n",
]
)
dockerfile.flush()
yield Path(dockerfile.name)

def test_build_docker_image_and_remove_on_cleanup(
self, dockerfile_hello_world: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("DOCKER_BUILDKIT", raising=False)

with EphemeralDockerImage(dockerfile_hello_world) as image:
assert get_docker_image(image_id=str(image.id))

with pytest.raises(ImageNotFound):
get_docker_image(image_id=str(image.id))

def test_build_with_docker_buildkit(self, dockerfile_buildkit: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DOCKER_BUILDKIT", "1")

with EphemeralDockerImage(dockerfile_buildkit) as image:
assert get_docker_image(image_id=str(image.id))

def test_build_error_when_docker_buildkit_envvar_not_set(
self, dockerfile_buildkit: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("DOCKER_BUILDKIT", raising=False)

with pytest.raises(BuildError), EphemeralDockerImage(dockerfile_buildkit):
pass
5 changes: 3 additions & 2 deletions tests/fixtures/test_tomodachi_fixures.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@ def test_tomodachi_image_id_set_from_envvar(pytester: pytest.Pytester) -> None:
dedent(
"""\
from docker.models.images import Image as DockerImage
from testcontainers.core.docker_client import DockerClient
from tomodachi_testcontainers.containers.common import get_docker_image
def test_tomodachi_image_id_set_from_envvar(tomodachi_image: DockerImage) -> None:
image = DockerClient().client.images.get("alpine:3.18.2")
image = get_docker_image("alpine:3.18.2")
assert tomodachi_image.id == image.id
"""
Expand Down

0 comments on commit c49e775

Please sign in to comment.