diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b05ea16..98b9bad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,9 @@ jobs: - name: Install dependencies run: make install-deps + - name: Pull Docker images for integration tests + run: timeout 300 docker pull eclipse-mosquitto:latest + - name: Run checks run: make check diff --git a/Makefile b/Makefile index cb205df..a704f47 100644 --- a/Makefile +++ b/Makefile @@ -42,12 +42,7 @@ test-unit: .PHONY: test-integration test-integration: -# TODO: Figure out why integration tests time out in CI environment. -ifdef CI - $(warning skipping integration tests in CI environment) -else pipenv run pytest -vv --capture=no tests/integration -endif .PHONY: get-pipenv get-pipenv: diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 52549f2..d3494e1 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -10,6 +10,8 @@ import enapter MOSQUITTO_PORT = "1883/tcp" +# Timeout for Docker image pull operations (in seconds) +DOCKER_PULL_TIMEOUT = 300 # 5 minutes @pytest.fixture(name="enapter_mqtt_client") @@ -32,6 +34,20 @@ def fixture_mosquitto_container( docker_client: docker.DockerClient, ) -> Generator[docker.models.containers.Container, None, None]: name = "enapter-python-sdk-integration-tests-mosquitto" + image = os.getenv("MOSQUITTO_IMAGE", "eclipse-mosquitto:latest") + + # Ensure the image is available locally + try: + docker_client.images.get(image) + except docker.errors.ImageNotFound: + # Pull the image if not available locally + # This is handled by the CI workflow, but we keep it here for local testing + pull_docker_image_with_timeout(docker_client, image) + except docker.errors.APIError as e: + raise RuntimeError( + f"Failed to access Docker daemon or image {image}. " + f"Please ensure Docker is running. Error: {e}" + ) from e try: old_mosquitto = docker_client.containers.get(name) @@ -41,7 +57,7 @@ def fixture_mosquitto_container( old_mosquitto.remove(force=True) mosquitto = docker_client.containers.run( - os.getenv("MOSQUITTO_IMAGE", "eclipse-mosquitto:latest"), + image, ["mosquitto", "-c", "/mosquitto-no-auth.conf"], name=name, network="bridge", @@ -63,6 +79,46 @@ def random_unused_port() -> int: return addr[1] +def pull_docker_image_with_timeout( + docker_client: docker.DockerClient, image: str, timeout: int = DOCKER_PULL_TIMEOUT +) -> None: + """Pull a Docker image with a timeout. + + Args: + docker_client: Docker client instance + image: Image name to pull + timeout: Timeout in seconds (default: DOCKER_PULL_TIMEOUT) + + Raises: + RuntimeError: If the image pull fails or times out + TimeoutError: If the operation exceeds the timeout + """ + import concurrent.futures + + def _pull_image() -> None: + try: + docker_client.images.pull(image) + except docker.errors.APIError as e: + raise RuntimeError( + f"Failed to pull Docker image {image}. " + f"Please ensure Docker is running and you have network connectivity. " + f"Error: {e}" + ) from e + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(_pull_image) + try: + future.result(timeout=timeout) + except concurrent.futures.TimeoutError as e: + raise TimeoutError( + f"Timeout while pulling Docker image {image} after {timeout} seconds. " + f"Please check your network connection or increase the timeout." + ) from e + except Exception: + # Re-raise any other exceptions from the pull operation + raise + + @pytest.fixture(name="docker_client", scope="session") def fixture_docker_client() -> Generator[docker.DockerClient, None, None]: docker_client = docker.from_env()