Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Find the latest documentation on `dcermak.github.io/pytest_container
<https://dcermak.github.io/pytest_container/>`_.

``pytest_container`` is a `pytest <https://pytest.org>`_ plugin
to test container images via pytest fixtures and `testinfra
to test container images via pytest fixtures and optionally `testinfra
<https://testinfra.readthedocs.io/en/latest/>`_. It takes care of all the boring
tasks, like spinning up containers, finding free ports and cleaning up after
tests, and allows you to focus on implementing the actual tests.
Expand Down Expand Up @@ -55,8 +55,7 @@ instantiating a ``Container`` and parametrizing a test function with the
The fixture automatically pulls and spins up the container, stops it and removes
it after the test is completed. Your test function receives an instance of
``ContainerData`` with the ``ContainerData.connection`` attribute. The
``ContainerData.connection`` attribute is a `testinfra
<https://testinfra.readthedocs.io/en/latest/>`_ connection object. It can be
``ContainerData.connection`` attribute is a shell connection object. It can be
used to run basic tests inside the container itself. For example, you can check
whether files are present, packages are installed, etc.

Expand Down
44 changes: 38 additions & 6 deletions pytest_container/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@
import _pytest.mark
import deprecation
import pytest
import testinfra
from filelock import BaseFileLock
from filelock import FileLock

from pytest_container import helpers
from pytest_container.helpers import get_always_pull_option
from pytest_container.helpers import get_extra_build_args
from pytest_container.helpers import get_extra_run_args
Expand Down Expand Up @@ -877,7 +877,7 @@
@dataclass(frozen=True)
class ContainerData:
"""Class returned by the ``*container*`` fixtures to the test function. It
contains information about the launched container and the testinfra
contains information about the launched container and its shell connection
:py:attr:`connection` to the running container.

"""
Expand All @@ -887,7 +887,7 @@
image_url_or_id: str
#: ID of the started container
container_id: str
#: the testinfra connection to the running container
#: the shell connection to the running container
connection: Any
#: the container data class that has been used in this test
container: Union[Container, DerivedContainer]
Expand Down Expand Up @@ -1013,6 +1013,28 @@
raise ValueError(f"Invalid pytest.param values: {param.values}")


@dataclass(frozen=True)
class ContainerRemoteEndpoint:
_container_id: str
_runtime: OciRuntimeBase

def __post_init__(self) -> None:
assert self._container_id, "Container ID must not be empty"

Check warning on line 1022 in pytest_container/container.py

View check run for this annotation

Codecov / codecov/patch

pytest_container/container.py#L1022

Added line #L1022 was not covered by tests

def check_output(self, cmd: str, strip: bool = True) -> str:
"""Run a command in the container and return its output."""
return helpers.run_command(

Check warning on line 1026 in pytest_container/container.py

View check run for this annotation

Codecov / codecov/patch

pytest_container/container.py#L1026

Added line #L1026 was not covered by tests
[
self._runtime.runner_binary,
"exec",
self._container_id,
"/bin/sh",
"-c",
cmd,
],
)[1]


@dataclass
class ContainerLauncher:
"""Helper context manager to setup, start and teardown a container including
Expand Down Expand Up @@ -1174,12 +1196,22 @@
"""
if not self._container_id:
raise RuntimeError(f"Container {self.container} has not started")
connection: Any = None
try:
import testinfra

connection = testinfra.get_host(
f"{self.container_runtime.runner_binary}://{self._container_id}"
)
except ImportError:
connection = ContainerRemoteEndpoint(

Check warning on line 1207 in pytest_container/container.py

View check run for this annotation

Codecov / codecov/patch

pytest_container/container.py#L1206-L1207

Added lines #L1206 - L1207 were not covered by tests
self._container_id, self.container_runtime
)

return ContainerData(
image_url_or_id=self.container.url or self.container.container_id,
container_id=self._container_id,
connection=testinfra.get_host(
f"{self.container_runtime.runner_binary}://{self._container_id}"
),
connection=connection,
container=self.container,
forwarded_ports=self._new_port_forwards,
_container_runtime=self.container_runtime,
Expand Down
26 changes: 26 additions & 0 deletions pytest_container/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@

import logging
import os
import subprocess
from typing import List
from typing import Tuple

from _pytest.config import Config
from _pytest.config.argparsing import Parser
from _pytest.python import Metafunc

from pytest_container.logging import _logger
from pytest_container.logging import set_internal_logging_level


Expand Down Expand Up @@ -157,3 +160,26 @@

"""
return bool(int(os.getenv("PULL_ALWAYS", "1")))


def run_command(
cmd: List[str],
ignore_errors=True,
) -> Tuple[int, str, str]:
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=not ignore_errors,
)

_logger.debug(
f"RUN CMD: {cmd} RC: {result.returncode} STDOUT: {result.stdout} STDERR:{result.stderr}"
)
return result.returncode, result.stdout, result.stderr
except subprocess.CalledProcessError as exc:
_logger.debug(

Check warning on line 182 in pytest_container/helpers.py

View check run for this annotation

Codecov / codecov/patch

pytest_container/helpers.py#L181-L182

Added lines #L181 - L182 were not covered by tests
f"RUN(Failed) CMD: {cmd} RC: {exc.returncode} STDOUT:{exc.stdout} STDERR:{exc.stderr}"
)
raise exc

Check warning on line 185 in pytest_container/helpers.py

View check run for this annotation

Codecov / codecov/patch

pytest_container/helpers.py#L185

Added line #L185 was not covered by tests
35 changes: 19 additions & 16 deletions pytest_container/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import json
import re
import shutil
import sys
from abc import ABC
from abc import abstractmethod
Expand All @@ -20,10 +21,10 @@
from typing import Optional
from typing import Union

import testinfra
from _pytest.mark.structures import ParameterSet
from pytest import param

from pytest_container import helpers
from pytest_container.inspect import BindMount
from pytest_container.inspect import Config
from pytest_container.inspect import ContainerHealth
Expand Down Expand Up @@ -411,9 +412,6 @@ def __str__(self) -> str:
return self.__class__.__name__


LOCALHOST = testinfra.host.get_host("local://")


def _get_podman_version(version_stdout: str) -> Version:
if version_stdout[:15] != "podman version ":
raise RuntimeError(
Expand All @@ -424,7 +422,7 @@ def _get_podman_version(version_stdout: str) -> Version:


def _get_buildah_version() -> Version:
version_stdout = LOCALHOST.check_output("buildah --version")
version_stdout = helpers.run_command(["buildah", "--version"])[1]
build_version_begin = "buildah version "
if not version_stdout.startswith(build_version_begin):
raise RuntimeError(
Expand All @@ -443,11 +441,11 @@ class PodmanRuntime(OciRuntimeBase):
"""

def __init__(self) -> None:
podman_ps = LOCALHOST.run("podman ps")
if not podman_ps.succeeded:
raise RuntimeError(f"`podman ps` failed with {podman_ps.stderr}")
podman_ps = helpers.run_command(["podman", "ps"])
if not podman_ps[0] == 0:
raise RuntimeError(f"`podman ps` failed with {podman_ps[2]}")

self._buildah_functional = LOCALHOST.run("buildah").succeeded
self._buildah_functional = helpers.run_command(["buildah"])[0] == 0
super().__init__(
build_command=(
["buildah", "bud", "--layers", "--force-rm"]
Expand All @@ -461,8 +459,11 @@ def __init__(self) -> None:
@cached_property
def version(self) -> Version:
"""Returns the version of podman installed on the system"""

return _get_podman_version(
LOCALHOST.run_expect([0], "podman --version").stdout
helpers.run_command(["podman", "--version"], ignore_errors=False)[
1
]
)

@cached_property
Expand Down Expand Up @@ -539,9 +540,9 @@ class DockerRuntime(OciRuntimeBase):
containers."""

def __init__(self) -> None:
docker_ps = LOCALHOST.run("docker ps")
if not docker_ps.succeeded:
raise RuntimeError(f"`docker ps` failed with {docker_ps.stderr}")
docker_ps = helpers.run_command(["docker", "ps"])
if not docker_ps[0] == 0:
raise RuntimeError(f"`docker ps` failed with {docker_ps[2]}")

super().__init__(
build_command=["docker", "build", "--force-rm"],
Expand All @@ -552,7 +553,9 @@ def __init__(self) -> None:
def version(self) -> Version:
"""Returns the version of docker installed on this system"""
return _get_docker_version(
LOCALHOST.run_expect([0], "docker --version").stdout
helpers.run_command(["docker", "--version"], ignore_errors=False)[
1
]
)

@property
Expand Down Expand Up @@ -613,8 +616,8 @@ def get_selected_runtime() -> OciRuntimeBase:

If neither docker nor podman are available, then a ValueError is raised.
"""
podman_exists = LOCALHOST.exists("podman")
docker_exists = LOCALHOST.exists("docker")
podman_exists = shutil.which("podman")
docker_exists = shutil.which("docker")

runtime_choice = getenv("CONTAINER_RUNTIME", "podman").lower()
if runtime_choice not in ("podman", "docker"):
Expand Down
1 change: 1 addition & 0 deletions test-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ pytest-xdist
coverage
pytest-rerunfailures
typeguard
ifaddr
.
35 changes: 24 additions & 11 deletions tests/test_container_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
from pytest_container.container import ContainerData
from pytest_container.container import ContainerLauncher
from pytest_container.container import EntrypointSelection
from pytest_container.helpers import run_command
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 @@ -285,9 +285,9 @@ def test_multistage_build_target(
extra_build_args=get_extra_build_args(pytestconfig),
)
assert (
LOCALHOST.check_output(
f"{container_runtime.runner_binary} run --rm {first_target}",
).strip()
run_command(
[container_runtime.runner_binary, "run", "--rm", first_target]
)[1].strip()
== "foobar"
)

Expand All @@ -301,9 +301,15 @@ def test_multistage_build_target(

assert first_target != second_target
assert (
LOCALHOST.check_output(
f"{container_runtime.runner_binary} run --rm {second_target} /bin/test.sh",
).strip()
run_command(
[
container_runtime.runner_binary,
"run",
"--rm",
second_target,
"/bin/test.sh",
]
)[1].strip()
== "foobar"
)

Expand All @@ -313,10 +319,17 @@ def test_multistage_build_target(
):
assert (
distro
in LOCALHOST.check_output(
f"{container_runtime.runner_binary} run --rm --entrypoint= {target} "
"cat /etc/os-release",
).strip()
in run_command(
[
container_runtime.runner_binary,
"run",
"--rm",
"--entrypoint=",
target,
"cat",
"/etc/os-release",
]
)[1].strip()
)


Expand Down
16 changes: 12 additions & 4 deletions tests/test_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from pytest_container.container import ContainerVolume
from pytest_container.container import DerivedContainer
from pytest_container.container import EntrypointSelection
from pytest_container.runtime import LOCALHOST
from pytest_container.helpers import run_command
from pytest_container.runtime import OciRuntimeBase

from .images import CMDLINE_APP_CONTAINER
Expand Down Expand Up @@ -101,9 +101,17 @@ def test_launcher_creates_and_cleanes_up_volumes(
assert vol.host_path and os.path.exists(vol.host_path)
elif isinstance(vol, ContainerVolume):
assert vol.volume_id
assert LOCALHOST.run_expect(
[0],
f"{container_runtime.runner_binary} volume inspect {vol.volume_id}",
assert (
run_command(
[
container_runtime.runner_binary,
"volume",
"inspect",
vol.volume_id,
],
ignore_errors=False,
)[0]
== 0
)
else:
assert False, f"invalid volume type {type(vol)}"
Expand Down
Loading
Loading