diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index d7e68b3d..62f0dbcd 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "4.8.0" + ".": "4.8.1" } diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index 0f6a5e4e..4ee4f4a5 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -33,6 +33,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: "coverage-artifact-${{ matrix.python-version}}" + include-hidden-files: true path: ".coverage.*" retention-days: 1 - name: Run doctests diff --git a/CHANGELOG.md b/CHANGELOG.md index 517bb5ac..c3d5b7ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [4.8.1](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.8.0...testcontainers-v4.8.1) (2024-08-18) + + +### Bug Fixes + +* **generic:** Update the FastAPI install on genric module doctest samples ([#686](https://github.com/testcontainers/testcontainers-python/issues/686)) ([5216b02](https://github.com/testcontainers/testcontainers-python/commit/5216b0241a27afe3419f5c4a6d500dc27154ddd4)) +* **mssql:** use glob to find mssql-tools folder since it moves ([#685](https://github.com/testcontainers/testcontainers-python/issues/685)) ([4912725](https://github.com/testcontainers/testcontainers-python/commit/4912725c2a54a9edce046416fbf11e089cc03cb0)), closes [#666](https://github.com/testcontainers/testcontainers-python/issues/666) +* wait_for_logs can now fail early when the container stops ([#682](https://github.com/testcontainers/testcontainers-python/issues/682)) ([925329d](https://github.com/testcontainers/testcontainers-python/commit/925329d8d2df78437a491a29b707d5ac97e7b734)) + + +### Documentation + +* Add a more advance usecase documentation for ServerContainer ([#688](https://github.com/testcontainers/testcontainers-python/issues/688)) ([2cf5a9f](https://github.com/testcontainers/testcontainers-python/commit/2cf5a9fbe6db3fa4254a5bb54e67412ec2d08488)) + ## [4.8.0](https://github.com/testcontainers/testcontainers-python/compare/testcontainers-v4.7.2...testcontainers-v4.8.0) (2024-08-14) diff --git a/core/testcontainers/core/auth.py b/core/testcontainers/core/auth.py index 906dca1d..afabe9e9 100644 --- a/core/testcontainers/core/auth.py +++ b/core/testcontainers/core/auth.py @@ -2,7 +2,7 @@ import json as json from collections import namedtuple from logging import warning -from typing import Optional +from typing import Any, Optional DockerAuthInfo = namedtuple("DockerAuthInfo", ["registry", "username", "password"]) @@ -12,7 +12,7 @@ } -def process_docker_auth_config_encoded(auth_config_dict: dict) -> list[DockerAuthInfo]: +def process_docker_auth_config_encoded(auth_config_dict: dict[str, dict[str, dict[str, Any]]]) -> list[DockerAuthInfo]: """ Process the auths config. @@ -30,8 +30,11 @@ def process_docker_auth_config_encoded(auth_config_dict: dict) -> list[DockerAut auth_info: list[DockerAuthInfo] = [] auths = auth_config_dict.get("auths") + if not auths: + raise KeyError("No auths found in the docker auth config") + for registry, auth in auths.items(): - auth_str = auth.get("auth") + auth_str = str(auth.get("auth")) auth_str = base64.b64decode(auth_str).decode("utf-8") username, password = auth_str.split(":") auth_info.append(DockerAuthInfo(registry, username, password)) @@ -39,7 +42,7 @@ def process_docker_auth_config_encoded(auth_config_dict: dict) -> list[DockerAut return auth_info -def process_docker_auth_config_cred_helpers(auth_config_dict: dict) -> None: +def process_docker_auth_config_cred_helpers(auth_config_dict: dict[str, Any]) -> None: """ Process the credHelpers config. @@ -56,7 +59,7 @@ def process_docker_auth_config_cred_helpers(auth_config_dict: dict) -> None: warning(_AUTH_WARNINGS.pop("credHelpers")) -def process_docker_auth_config_store(auth_config_dict: dict) -> None: +def process_docker_auth_config_store(auth_config_dict: dict[str, Any]) -> None: """ Process the credsStore config. @@ -74,7 +77,7 @@ def process_docker_auth_config_store(auth_config_dict: dict) -> None: def parse_docker_auth_config(auth_config: str) -> Optional[list[DockerAuthInfo]]: """Parse the docker auth config from a string and handle the different formats.""" try: - auth_config_dict: dict = json.loads(auth_config) + auth_config_dict: dict[str, Any] = json.loads(auth_config) if "credHelpers" in auth_config: process_docker_auth_config_cred_helpers(auth_config_dict) if "credsStore" in auth_config: diff --git a/core/testcontainers/core/config.py b/core/testcontainers/core/config.py index 7b727951..142dbc19 100644 --- a/core/testcontainers/core/config.py +++ b/core/testcontainers/core/config.py @@ -30,7 +30,7 @@ def read_tc_properties() -> dict[str, str]: tc_files = [item for item in [TC_GLOBAL] if exists(item)] if not tc_files: return {} - settings = {} + settings: dict[str, str] = {} for file in tc_files: with open(file) as contents: @@ -60,14 +60,14 @@ class TestcontainersConfiguration: """ @property - def docker_auth_config(self): + def docker_auth_config(self) -> Optional[str]: config = self._docker_auth_config if config and "DOCKER_AUTH_CONFIG" in _WARNINGS: warning(_WARNINGS.pop("DOCKER_AUTH_CONFIG")) return config @docker_auth_config.setter - def docker_auth_config(self, value: str): + def docker_auth_config(self, value: str) -> None: if "DOCKER_AUTH_CONFIG" in _WARNINGS: warning(_WARNINGS.pop("DOCKER_AUTH_CONFIG")) self._docker_auth_config = value @@ -76,7 +76,7 @@ def tc_properties_get_tc_host(self) -> Union[str, None]: return self.tc_properties.get("tc.host") @property - def timeout(self): + def timeout(self) -> int: return self.max_tries * self.sleep_time diff --git a/core/testcontainers/core/utils.py b/core/testcontainers/core/utils.py index 5ca1c2f7..4a7bec4b 100644 --- a/core/testcontainers/core/utils.py +++ b/core/testcontainers/core/utils.py @@ -3,6 +3,7 @@ import platform import subprocess import sys +from typing import Any, Optional LINUX = "linux" MAC = "mac" @@ -18,7 +19,7 @@ def setup_logger(name: str) -> logging.Logger: return logger -def os_name() -> str: +def os_name() -> Optional[str]: pl = sys.platform if pl == "linux" or pl == "linux2": return LINUX @@ -26,6 +27,7 @@ def os_name() -> str: return MAC elif pl == "win32": return WIN + return None def is_mac() -> bool: @@ -53,7 +55,7 @@ def inside_container() -> bool: return os.path.exists("/.dockerenv") -def default_gateway_ip() -> str: +def default_gateway_ip() -> Optional[str]: """ Returns gateway IP address of the host that testcontainer process is running on @@ -66,11 +68,12 @@ def default_gateway_ip() -> str: ip_address = process.communicate()[0] if ip_address and process.returncode == 0: return ip_address.decode("utf-8").strip().strip("\n") + return None except subprocess.SubprocessError: return None -def raise_for_deprecated_parameter(kwargs: dict, name: str, replacement: str) -> dict: +def raise_for_deprecated_parameter(kwargs: dict[Any, Any], name: str, replacement: str) -> dict[Any, Any]: """ Raise an error if a dictionary of keyword arguments contains a key and suggest the replacement. """ diff --git a/core/tests/test_auth.py b/core/tests/test_auth.py index a7581f42..0e163981 100644 --- a/core/tests/test_auth.py +++ b/core/tests/test_auth.py @@ -7,6 +7,7 @@ def test_parse_docker_auth_config_encoded(): auth_config_json = '{"auths":{"https://index.docker.io/v1/":{"auth":"dXNlcm5hbWU6cGFzc3dvcmQ="}}}' auth_info = parse_docker_auth_config(auth_config_json) + assert auth_info assert len(auth_info) == 1 assert auth_info[0] == DockerAuthInfo( registry="https://index.docker.io/v1/", @@ -37,6 +38,7 @@ def test_parse_docker_auth_config_encoded_multiple(): } auth_config_json = json.dumps(auth_dict) auth_info = parse_docker_auth_config(auth_config_json) + assert auth_info assert len(auth_info) == 3 assert auth_info[0] == DockerAuthInfo( registry="localhost:5000", diff --git a/core/tests/test_config.py b/core/tests/test_config.py new file mode 100644 index 00000000..a6597fd4 --- /dev/null +++ b/core/tests/test_config.py @@ -0,0 +1,62 @@ +from testcontainers.core.config import TestcontainersConfiguration as TCC, TC_FILE + +from pytest import MonkeyPatch, mark, LogCaptureFixture + +import logging +import tempfile + + +def test_read_tc_properties(monkeypatch: MonkeyPatch) -> None: + with tempfile.TemporaryDirectory() as tmpdirname: + file = f"{tmpdirname}/{TC_FILE}" + with open(file, "w") as f: + f.write("tc.host=some_value\n") + + monkeypatch.setattr("testcontainers.core.config.TC_GLOBAL", file) + + config = TCC() + assert config.tc_properties == {"tc.host": "some_value"} + + +@mark.parametrize("docker_auth_config_env", ["key=value", ""]) +@mark.parametrize("warning_dict", [{}, {"key": "value"}, {"DOCKER_AUTH_CONFIG": "TEST"}]) +@mark.parametrize("warning_dict_post", [{}, {"key": "value"}, {"DOCKER_AUTH_CONFIG": "TEST"}]) +def test_docker_auth_config( + caplog: LogCaptureFixture, + monkeypatch: MonkeyPatch, + docker_auth_config_env: str, + warning_dict: dict[str, str], + warning_dict_post: dict[str, str], +) -> None: + monkeypatch.setattr("testcontainers.core.config._WARNINGS", warning_dict) + monkeypatch.setenv("DOCKER_AUTH_CONFIG", docker_auth_config_env) + caplog.set_level(logging.WARNING) + + config = TCC() + if not docker_auth_config_env: + assert config.docker_auth_config == "" + assert caplog.text == "" + else: + assert config.docker_auth_config == docker_auth_config_env + + if "DOCKER_AUTH_CONFIG" in warning_dict: + assert warning_dict["DOCKER_AUTH_CONFIG"] in caplog.text + + if warning_dict == {}: + monkeypatch.setattr("testcontainers.core.config._WARNINGS", warning_dict_post) + + config.docker_auth_config = "new_value" + assert config.docker_auth_config == "new_value" + + +def test_tc_properties_get_tc_host() -> None: + config = TCC() + config.tc_properties = {"tc.host": "some_value"} + assert config.tc_properties_get_tc_host() == "some_value" + + +def test_timeout() -> None: + config = TCC() + config.max_tries = 2 + config.sleep_time = 3 + assert config.timeout == 6 diff --git a/core/tests/test_core.py b/core/tests/test_core.py index 3a592cdc..59d7b2a7 100644 --- a/core/tests/test_core.py +++ b/core/tests/test_core.py @@ -1,19 +1,4 @@ -import pytest -import tempfile -import random -import os - -from pathlib import Path -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 - - -def test_timeout_is_raised_when_waiting_for_logs(): - with pytest.raises(TimeoutError), DockerContainer("alpine").with_command("sleep 2") as container: - wait_for_logs(container, "Hello from Docker!", timeout=1e-3) def test_garbage_collection_is_defensive(): @@ -26,72 +11,12 @@ def test_garbage_collection_is_defensive(): del container -def test_wait_for_hello(): +def test_get_logs(): with DockerContainer("hello-world") as container: - wait_for_logs(container, "Hello from Docker!") - - -def test_can_get_logs(): - with DockerContainer("hello-world") as container: - wait_for_logs(container, "Hello from Docker!") stdout, stderr = container.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) - - -@pytest.mark.parametrize("dockerfile_path", [None, Path("subdir/my.Dockerfile")]) -def test_docker_image_with_custom_dockerfile_path(dockerfile_path: Optional[Path]): - with tempfile.TemporaryDirectory() as temp_directory: - temp_dir_path = Path(temp_directory) - if dockerfile_path: - os.makedirs(temp_dir_path / dockerfile_path.parent, exist_ok=True) - dockerfile_rel_path = dockerfile_path - dockerfile_kwargs = {"dockerfile_path": dockerfile_path} - else: - dockerfile_rel_path = Path("Dockerfile") # default - dockerfile_kwargs = {} - - with open(temp_dir_path / dockerfile_rel_path, "x") as f: - f.write( - f""" - FROM alpine:latest - CMD echo "Hello world!" - """ - ) - with DockerImage(path=temp_directory, tag="test", clean_up=True, no_cache=True, **dockerfile_kwargs) as image: - image_short_id = image.short_id - with DockerContainer(str(image)) as container: - assert container._container.image.short_id.endswith(image_short_id), "Image ID mismatch" - assert container.get_logs() == (("Hello world!\n").encode(), b""), "Container logs mismatch" + assert "Hello from Docker".encode() in stdout, "There should be something on stdout" def test_docker_container_with_env_file(): diff --git a/core/tests/test_image.py b/core/tests/test_image.py new file mode 100644 index 00000000..da35eda0 --- /dev/null +++ b/core/tests/test_image.py @@ -0,0 +1,66 @@ +import pytest +import tempfile +import random +import os + +from pathlib import Path +from typing import Optional + +from testcontainers.core.container import DockerContainer +from testcontainers.core.image import DockerImage + + +@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) -> None: + 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" + assert image.get_wrapped_image() is not 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) + + +@pytest.mark.parametrize("dockerfile_path", [None, Path("subdir/my.Dockerfile")]) +def test_docker_image_with_custom_dockerfile_path(dockerfile_path: Optional[Path]) -> None: + with tempfile.TemporaryDirectory() as temp_directory: + temp_dir_path = Path(temp_directory) + if dockerfile_path: + os.makedirs(temp_dir_path / dockerfile_path.parent, exist_ok=True) + dockerfile_rel_path = dockerfile_path + dockerfile_kwargs = {"dockerfile_path": dockerfile_path} + else: + dockerfile_rel_path = Path("Dockerfile") # default + dockerfile_kwargs = {} + + with open(temp_dir_path / dockerfile_rel_path, "x") as f: + f.write( + f""" + FROM alpine:latest + CMD echo "Hello world!" + """ + ) + with DockerImage(path=temp_directory, tag="test", clean_up=True, no_cache=True, **dockerfile_kwargs) as image: + image_short_id = image.short_id + assert image.get_wrapped_image() is not None + with DockerContainer(str(image)) as container: + assert container._container.image.short_id.endswith(image_short_id), "Image ID mismatch" + assert container.get_logs() == (("Hello world!\n").encode(), b""), "Container logs mismatch" diff --git a/core/tests/test_utils.py b/core/tests/test_utils.py new file mode 100644 index 00000000..01134327 --- /dev/null +++ b/core/tests/test_utils.py @@ -0,0 +1,53 @@ +from pytest import MonkeyPatch, raises, mark + +from testcontainers.core import utils + + +def test_setup_logger() -> None: + assert utils.setup_logger("test") is not None + + +@mark.parametrize("platform, expected", [("linux", "linux"), ("linux2", "linux"), ("darwin", "mac"), ("win32", "win")]) +def test_os_name(monkeypatch: MonkeyPatch, platform: str, expected: str) -> None: + assert utils.os_name() is not None + monkeypatch.setattr("sys.platform", platform) + assert utils.os_name() == expected + + +def test_is_mac(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setattr("testcontainers.core.utils.os_name", lambda: "mac") + assert utils.is_mac() + + +def test_is_linux(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setattr("testcontainers.core.utils.os_name", lambda: "linux") + assert utils.is_linux() + + +def test_is_windows(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setattr("testcontainers.core.utils.os_name", lambda: "win") + assert utils.is_windows() + + +def test_is_arm(monkeypatch: MonkeyPatch) -> None: + assert not utils.is_arm() + monkeypatch.setattr("platform.machine", lambda: "arm64") + assert utils.is_arm() + monkeypatch.setattr("platform.machine", lambda: "aarch64") + assert utils.is_arm() + + +def test_inside_container(monkeypatch: MonkeyPatch) -> None: + assert not utils.inside_container() + monkeypatch.setattr("os.path.exists", lambda _: True) + assert utils.inside_container() + + +def test_raise_for_deprecated_parameters() -> None: + kwargs = {"key": "value"} + current = "key" + replacement = "new_key" + with raises(ValueError) as e: + result = utils.raise_for_deprecated_parameter(kwargs, current, replacement) + assert str(e.value) == "Parameter 'deprecated' is deprecated and should be replaced by 'replacement'." + assert result == {} diff --git a/core/tests/test_waiting_utils.py b/core/tests/test_waiting_utils.py new file mode 100644 index 00000000..1e684fc4 --- /dev/null +++ b/core/tests/test_waiting_utils.py @@ -0,0 +1,14 @@ +import pytest + +from testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_for_logs + + +def test_wait_for_logs() -> None: + with DockerContainer("hello-world") as container: + wait_for_logs(container, "Hello from Docker!") + + +def test_timeout_is_raised_when_waiting_for_logs() -> None: + with pytest.raises(TimeoutError), DockerContainer("alpine").with_command("sleep 2") as container: + wait_for_logs(container, "Hello from Docker!", timeout=1e-3) diff --git a/modules/keycloak/testcontainers/keycloak/__init__.py b/modules/keycloak/testcontainers/keycloak/__init__.py index 6addf09a..e7a06521 100644 --- a/modules/keycloak/testcontainers/keycloak/__init__.py +++ b/modules/keycloak/testcontainers/keycloak/__init__.py @@ -34,7 +34,7 @@ class KeycloakContainer(DockerContainer): >>> from testcontainers.keycloak import KeycloakContainer - >>> with KeycloakContainer(f"quay.io/keycloak/keycloak:24.0.1") as keycloak: + >>> with KeycloakContainer(f"quay.io/keycloak/keycloak:25.0.4") as keycloak: ... keycloak.get_client().users_count() 1 """ @@ -45,13 +45,15 @@ def __init__( username: Optional[str] = None, password: Optional[str] = None, port: int = 8080, + management_port: int = 9000, cmd: Optional[str] = _DEFAULT_DEV_COMMAND, ) -> None: super().__init__(image=image) self.username = username or os.environ.get("KEYCLOAK_ADMIN", "test") self.password = password or os.environ.get("KEYCLOAK_ADMIN_PASSWORD", "test") self.port = port - self.with_exposed_ports(self.port) + self.management_port = management_port + self.with_exposed_ports(self.port, self.management_port) self.cmd = cmd def _configure(self) -> None: @@ -71,10 +73,20 @@ def get_url(self) -> str: port = self.get_exposed_port(self.port) return f"http://{host}:{port}" + def get_management_url(self) -> str: + host = self.get_container_host_ip() + port = self.get_exposed_port(self.management_port) + return f"http://{host}:{port}" + @wait_container_is_ready(requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout) def _readiness_probe(self) -> None: # Keycloak provides REST API endpoints for health checks: https://www.keycloak.org/server/health - response = requests.get(f"{self.get_url()}/health/ready", timeout=1) + try: + # Try the new health endpoint for keycloak 25.0.0 and above + # See https://www.keycloak.org/docs/25.0.0/release_notes/#management-port-for-metrics-and-health-endpoints + response = requests.get(f"{self.get_management_url()}/health/ready", timeout=1) + except requests.exceptions.ConnectionError: + response = requests.get(f"{self.get_url()}/health/ready", timeout=1) response.raise_for_status() if _DEFAULT_DEV_COMMAND in self._command: wait_for_logs(self, "Added user .* to realm .*") diff --git a/modules/keycloak/tests/test_keycloak.py b/modules/keycloak/tests/test_keycloak.py index ce54e467..6bf003b7 100644 --- a/modules/keycloak/tests/test_keycloak.py +++ b/modules/keycloak/tests/test_keycloak.py @@ -2,7 +2,7 @@ from testcontainers.keycloak import KeycloakContainer -@pytest.mark.parametrize("image_version", ["24.0.1", "18.0"]) +@pytest.mark.parametrize("image_version", ["25.0", "24.0.1", "18.0"]) def test_docker_run_keycloak(image_version: str): with KeycloakContainer(f"quay.io/keycloak/keycloak:{image_version}") as keycloak_admin: assert keycloak_admin.get_client().users_count() == 1 diff --git a/modules/opensearch/testcontainers/opensearch/__init__.py b/modules/opensearch/testcontainers/opensearch/__init__.py index 06d3a767..8a02dbb0 100644 --- a/modules/opensearch/testcontainers/opensearch/__init__.py +++ b/modules/opensearch/testcontainers/opensearch/__init__.py @@ -1,3 +1,5 @@ +from contextlib import suppress + from opensearchpy import OpenSearch from opensearchpy.exceptions import ConnectionError, TransportError from urllib3.exceptions import ProtocolError @@ -35,6 +37,7 @@ def __init__( image: str = "opensearchproject/opensearch:2.4.0", port: int = 9200, security_enabled: bool = False, + initial_admin_password: str = "admin", **kwargs, ) -> None: """ @@ -42,18 +45,29 @@ def __init__( image: Docker image to use for the container. port: Port to expose on the container. security_enabled: :code:`False` disables the security plugin in OpenSearch. + initial_admin_password: set the password for opensearch, For OpenSearch versions 2.12 and + later, you must set the initial admin password as seen in the documentation, + https://opensearch.org/docs/latest/security/configuration/demo-configuration/#setting-up-a-custom-admin-password """ raise_for_deprecated_parameter(kwargs, "port_to_expose", "port") super().__init__(image, **kwargs) self.port = port self.security_enabled = security_enabled + self.initial_admin_password = initial_admin_password self.with_exposed_ports(self.port) self.with_env("discovery.type", "single-node") self.with_env("plugins.security.disabled", "false" if security_enabled else "true") + if self._supports_initial_admin_password(str(image)): + self.with_env("OPENSEARCH_INITIAL_ADMIN_PASSWORD", self.initial_admin_password) if security_enabled: self.with_env("plugins.security.allow_default_init_securityindex", "true") + def _supports_initial_admin_password(self, image: str) -> bool: + with suppress(Exception): + return [int(n) for n in image.split(":")[-1].split(".")] >= [int(n) for n in "2.12.0".split(".")] + return False + def get_config(self) -> dict: """This method returns the configuration of the OpenSearch container, including the host, port, username, and password. @@ -66,7 +80,7 @@ def get_config(self) -> dict: "host": self.get_container_host_ip(), "port": self.get_exposed_port(self.port), "username": "admin", - "password": "admin", + "password": self.initial_admin_password, } def get_client(self, verify_certs: bool = False, **kwargs) -> OpenSearch: diff --git a/modules/opensearch/tests/test_opensearch.py b/modules/opensearch/tests/test_opensearch.py index a287563e..8a14b0b1 100644 --- a/modules/opensearch/tests/test_opensearch.py +++ b/modules/opensearch/tests/test_opensearch.py @@ -1,5 +1,20 @@ from testcontainers.opensearch import OpenSearchContainer +import pytest + + +@pytest.fixture(autouse=True) +def disable_logging(): + import logging + import warnings + + warnings.filterwarnings("ignore") + logging.getLogger("opensearch").setLevel(logging.CRITICAL) + + yield + warnings.resetwarnings() + logging.getLogger("opensearch").setLevel(logging.NOTSET) + def test_docker_run_opensearch(): with OpenSearchContainer() as opensearch: @@ -25,6 +40,14 @@ def test_docker_run_opensearch_v1_with_security(): assert client.cluster.health()["status"] == "green" +def test_docker_run_opensearch_v2_12(): + with OpenSearchContainer( + image="opensearchproject/opensearch:2.12.0", initial_admin_password="Testing!#345" + ) as opensearch: + client = opensearch.get_client() + assert client.cluster.health()["status"] == "green" + + def test_search(): with OpenSearchContainer() as opensearch: client = opensearch.get_client() diff --git a/pyproject.toml b/pyproject.toml index aa17f8c2..55f9499d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "testcontainers" -version = "4.8.0" # auto-incremented by release-please +version = "4.8.1" # auto-incremented by release-please description = "Python library for throwaway instances of anything that can run in a Docker container" authors = ["Sergey Pirogov "] maintainers = [