From 8045a806fcb6908567339a14f2f0d7a169461675 Mon Sep 17 00:00:00 2001 From: Mehdi Ben Abdallah Date: Fri, 28 Jun 2024 11:03:46 +0200 Subject: [PATCH] fix(cosmosdb): Add support for the CosmosDB Emulator (#579) Adds support for the [CosmosDB Emulator container](https://learn.microsoft.com/en-us/azure/cosmos-db/emulator) --------- Co-authored-by: Mehdi BEN ABDALLAH <@mbenabda> Co-authored-by: David Ankin --- index.rst | 82 ++++--------- modules/cosmosdb/README.rst | 5 + .../testcontainers/cosmosdb/__init__.py | 4 + .../testcontainers/cosmosdb/_emulator.py | 110 ++++++++++++++++++ .../cosmosdb/testcontainers/cosmosdb/_grab.py | 26 +++++ .../testcontainers/cosmosdb/mongodb.py | 47 ++++++++ .../cosmosdb/testcontainers/cosmosdb/nosql.py | 69 +++++++++++ modules/cosmosdb/tests/test_emulator.py | 8 ++ modules/cosmosdb/tests/test_mongodb.py | 16 +++ modules/cosmosdb/tests/test_nosql.py | 7 ++ poetry.lock | 18 ++- pyproject.toml | 3 + 12 files changed, 333 insertions(+), 62 deletions(-) create mode 100644 modules/cosmosdb/README.rst create mode 100644 modules/cosmosdb/testcontainers/cosmosdb/__init__.py create mode 100644 modules/cosmosdb/testcontainers/cosmosdb/_emulator.py create mode 100644 modules/cosmosdb/testcontainers/cosmosdb/_grab.py create mode 100644 modules/cosmosdb/testcontainers/cosmosdb/mongodb.py create mode 100644 modules/cosmosdb/testcontainers/cosmosdb/nosql.py create mode 100644 modules/cosmosdb/tests/test_emulator.py create mode 100644 modules/cosmosdb/tests/test_mongodb.py create mode 100644 modules/cosmosdb/tests/test_nosql.py diff --git a/index.rst b/index.rst index ead699b2..8c02832f 100644 --- a/index.rst +++ b/index.rst @@ -13,7 +13,6 @@ testcontainers-python testcontainers-python facilitates the use of Docker containers for functional and integration testing. The collection of packages currently supports the following features. .. toctree:: - :maxdepth: 1 core/README modules/index @@ -60,15 +59,12 @@ Installation ------------ The suite of testcontainers packages is available on `PyPI `_, -and the package can be installed using :code:`pip`. +and individual packages can be installed using :code:`pip`. -Version `4.0.0` onwards we do not support the `testcontainers-*` packages as it is unsustainable to maintain ownership. +Version `4.0.0` onwards we do not support the `testcontainers-*` packages as it is unsutainable to maintain ownership. Instead packages can be installed by specifying `extras `__, e.g., :code:`pip install testcontainers[postgres]`. -Please note, that community modules are supported on a best-effort basis and breaking changes DO NOT create major versions in the package. -Therefore, only the package core is strictly following SemVer. If your workflow is broken by a minor update, please look at the changelogs for guidance. - Custom Containers ----------------- @@ -84,75 +80,40 @@ For common use cases, you can also use the generic containers provided by the `t Docker in Docker (DinD) ----------------------- -When trying to launch Testcontainers from within a Docker container, e.g., in continuous integration testing, two things have to be provided: +When trying to launch a testcontainer from within a Docker container, e.g., in continuous integration testing, two things have to be provided: 1. The container has to provide a docker client installation. Either use an image that has docker pre-installed (e.g. the `official docker images `_) or install the client from within the `Dockerfile` specification. 2. The container has to have access to the docker daemon which can be achieved by mounting `/var/run/docker.sock` or setting the `DOCKER_HOST` environment variable as part of your `docker run` command. -Private Docker registry ------------------------ - -Using a private docker registry requires the `DOCKER_AUTH_CONFIG` environment variable to be set. -`official documentation `_ - -The value of this variable should be a JSON string containing the authentication information for the registry. - -Example: - -.. code-block:: bash - - DOCKER_AUTH_CONFIG='{"auths": {"https://myregistry.com": {"auth": "dXNlcm5hbWU6cGFzc3dvcmQ="}}}' - -In order to generate the JSON string, you can use the following command: - -.. code-block:: bash - - echo -n '{"auths": {"": {"auth": "'$(echo -n ":" | base64 -w 0)'"}}}' - -Fetching passwords from cloud providers: - -.. code-block:: bash - - ECR_PASSWORD = $(aws ecr get-login-password --region eu-west-1) - GCP_PASSWORD = $(gcloud auth print-access-token) - AZURE_PASSWORD = $(az acr login --name --expose-token --output tsv) - - Configuration ------------- -+-------------------------------------------+---------------------------------------------------+------------------------------------------+ -| Env Variable | Example | Description | -+===========================================+===================================================+==========================================+ -| ``TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE`` | ``/var/run/docker.sock`` | Path to Docker's socket used by ryuk | -+-------------------------------------------+---------------------------------------------------+------------------------------------------+ -| ``TESTCONTAINERS_RYUK_PRIVILEGED`` | ``false`` | Run ryuk as a privileged container | -+-------------------------------------------+---------------------------------------------------+------------------------------------------+ -| ``TESTCONTAINERS_RYUK_DISABLED`` | ``false`` | Disable ryuk | -+-------------------------------------------+---------------------------------------------------+------------------------------------------+ -| ``RYUK_CONTAINER_IMAGE`` | ``testcontainers/ryuk:0.7.0`` | Custom image for ryuk | -+-------------------------------------------+---------------------------------------------------+------------------------------------------+ -| ``DOCKER_AUTH_CONFIG`` | ``{"auths": {"": {"auth": ""}}}`` | Custom registry auth config | -+-------------------------------------------+---------------------------------------------------+------------------------------------------+ ++-------------------------------------------+-------------------------------+------------------------------------------+ +| Env Variable | Example | Description | ++===========================================+===============================+==========================================+ +| ``TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE`` | ``/var/run/docker.sock`` | Path to Docker's socket used by ryuk | ++-------------------------------------------+-------------------------------+------------------------------------------+ +| ``TESTCONTAINERS_RYUK_PRIVILEGED`` | ``false`` | Run ryuk as a privileged container | ++-------------------------------------------+-------------------------------+------------------------------------------+ +| ``TESTCONTAINERS_RYUK_DISABLED`` | ``false`` | Disable ryuk | ++-------------------------------------------+-------------------------------+------------------------------------------+ +| ``RYUK_CONTAINER_IMAGE`` | ``testcontainers/ryuk:0.7.0`` | Custom image for ryuk | ++-------------------------------------------+-------------------------------+------------------------------------------+ Development and Contributing ---------------------------- -We recommend you use a `Poetry `_ for development. -After having installed `poetry`, you can run the following snippet to set up your local dev environment. +We recommend you use a `virtual environment `_ for development (:code:`python>=3.7` is required). After setting up your virtual environment, you can install all dependencies and test the installation by running the following snippet. .. code-block:: bash - make install + poetry install --all-extras + make /tests Package Structure ^^^^^^^^^^^^^^^^^ -Testcontainers is a collection of `implicit namespace packages `__ -to decouple the development of different extensions, -e.g., :code:`testcontainers[mysql]` and :code:`testcontainers[postgres]` for MySQL and PostgreSQL database containers, respectively. - -The folder structure is as follows: +Testcontainers is a collection of `implicit namespace packages `__ to decouple the development of different extensions, e.g., :code:`testcontainers-mysql` and :code:`testcontainers-postgres` for MySQL and PostgreSQL database containers, respectively. The folder structure is as follows. .. code-block:: bash @@ -172,11 +133,10 @@ The folder structure is as follows: ... # README for this feature. README.rst + # Setup script for this feature. + setup.py Contributing a New Feature ^^^^^^^^^^^^^^^^^^^^^^^^^^ -You want to contribute a new feature or container? Great! -- We recommend you first `open an issue `_ -- Then follow the suggestions from the team -- We also have a Pull Request `template `_ for new containers! +You want to contribute a new feature or container? Great! You can do that in six steps as outlined `here __`. diff --git a/modules/cosmosdb/README.rst b/modules/cosmosdb/README.rst new file mode 100644 index 00000000..802cffa4 --- /dev/null +++ b/modules/cosmosdb/README.rst @@ -0,0 +1,5 @@ +.. autoclass:: testcontainers.cosmosdb.CosmosDBMongoEndpointContainer +.. title:: testcontainers.cosmosdb.CosmosDBMongoEndpointContainer + +.. autoclass:: testcontainers.cosmosdb.CosmosDBNoSQLEndpointContainer +.. title:: testcontainers.cosmosdb.CosmosDBNoSQLEndpointContainer diff --git a/modules/cosmosdb/testcontainers/cosmosdb/__init__.py b/modules/cosmosdb/testcontainers/cosmosdb/__init__.py new file mode 100644 index 00000000..619ddb3b --- /dev/null +++ b/modules/cosmosdb/testcontainers/cosmosdb/__init__.py @@ -0,0 +1,4 @@ +from .mongodb import CosmosDBMongoEndpointContainer +from .nosql import CosmosDBNoSQLEndpointContainer + +__all__ = ["CosmosDBMongoEndpointContainer", "CosmosDBNoSQLEndpointContainer"] diff --git a/modules/cosmosdb/testcontainers/cosmosdb/_emulator.py b/modules/cosmosdb/testcontainers/cosmosdb/_emulator.py new file mode 100644 index 00000000..161a01c2 --- /dev/null +++ b/modules/cosmosdb/testcontainers/cosmosdb/_emulator.py @@ -0,0 +1,110 @@ +import os +import socket +import ssl +from collections.abc import Iterable +from distutils.util import strtobool +from urllib.error import HTTPError, URLError +from urllib.request import urlopen + +from typing_extensions import Self + +from testcontainers.core.container import DockerContainer +from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs + +from . import _grab as grab + +__all__ = ["CosmosDBEmulatorContainer"] + +EMULATOR_PORT = 8081 + + +class CosmosDBEmulatorContainer(DockerContainer): + """ + Abstract class for CosmosDB Emulator endpoints. + + Concrete implementations for each endpoint is provided by a separate class: + NoSQLEmulatorContainer and MongoDBEmulatorContainer. + """ + + def __init__( + self, + image: str = os.getenv( + "AZURE_COSMOS_EMULATOR_IMAGE", "mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest" + ), + partition_count: int = os.getenv("AZURE_COSMOS_EMULATOR_PARTITION_COUNT", None), + enable_data_persistence: bool = strtobool(os.getenv("AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE", "false")), + key: str = os.getenv( + "AZURE_COSMOS_EMULATOR_KEY", + "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==", + ), + bind_ports: bool = strtobool(os.getenv("AZURE_COSMOS_EMULATOR_BIND_PORTS", "true")), + endpoint_ports: Iterable[int] = [], + **other_kwargs, + ): + super().__init__(image=image, **other_kwargs) + self.endpoint_ports = endpoint_ports + self.partition_count = partition_count + self.key = key + self.enable_data_persistence = enable_data_persistence + self.bind_ports = bind_ports + + @property + def host(self) -> str: + """ + Emulator host + """ + return self.get_container_host_ip() + + @property + def server_certificate_pem(self) -> bytes: + """ + PEM-encoded server certificate + """ + return self._cert_pem_bytes + + def start(self) -> Self: + self._configure() + super().start() + self._wait_until_ready() + self._cert_pem_bytes = self._download_cert() + return self + + def _configure(self) -> None: + all_ports = {EMULATOR_PORT, *self.endpoint_ports} + if self.bind_ports: + for port in all_ports: + self.with_bind_ports(port, port) + else: + self.with_exposed_ports(*all_ports) + + ( + self.with_env("AZURE_COSMOS_EMULATOR_PARTITION_COUNT", str(self.partition_count)) + .with_env("AZURE_COSMOS_EMULATOR_IP_ADDRESS_OVERRIDE", socket.gethostbyname(socket.gethostname())) + .with_env("AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE", str(self.enable_data_persistence)) + .with_env("AZURE_COSMOS_EMULATOR_KEY", str(self.key)) + ) + + def _wait_until_ready(self) -> Self: + wait_for_logs(container=self, predicate="Started\\s*$") + + if self.bind_ports: + self._wait_for_url(f"https://{self.host}:{EMULATOR_PORT}/_explorer/index.html") + self._wait_for_query_success() + + return self + + def _download_cert(self) -> bytes: + with grab.file( + self.get_wrapped_container(), + "/tmp/cosmos/appdata/.system/profiles/Client/AppData/Local/CosmosDBEmulator/emulator.pem", + ) as cert: + return cert.read() + + @wait_container_is_ready(HTTPError, URLError) + def _wait_for_url(self, url: str) -> Self: + with urlopen(url, context=ssl._create_unverified_context()) as response: + response.read() + return self + + def _wait_for_query_success(self) -> None: + pass diff --git a/modules/cosmosdb/testcontainers/cosmosdb/_grab.py b/modules/cosmosdb/testcontainers/cosmosdb/_grab.py new file mode 100644 index 00000000..e1895019 --- /dev/null +++ b/modules/cosmosdb/testcontainers/cosmosdb/_grab.py @@ -0,0 +1,26 @@ +import tarfile +import tempfile +from contextlib import contextmanager +from os import path +from pathlib import Path + +from docker.models.containers import Container + + +@contextmanager +def file(container: Container, target: str): + target_path = Path(target) + assert target_path.is_absolute(), "target must be an absolute path" + + with tempfile.TemporaryDirectory() as tmp: + archive = Path(tmp) / "grabbed.tar" + + # download from container as tar archive + with open(archive, "wb") as f: + tar_bits, _ = container.get_archive(target) + for chunk in tar_bits: + f.write(chunk) + + # extract target file from tar archive + with tarfile.TarFile(archive) as tar: + yield tar.extractfile(path.basename(target)) diff --git a/modules/cosmosdb/testcontainers/cosmosdb/mongodb.py b/modules/cosmosdb/testcontainers/cosmosdb/mongodb.py new file mode 100644 index 00000000..82e8c096 --- /dev/null +++ b/modules/cosmosdb/testcontainers/cosmosdb/mongodb.py @@ -0,0 +1,47 @@ +import os + +from ._emulator import CosmosDBEmulatorContainer + +__all__ = ["CosmosDBMongoEndpointContainer"] + +ENDPOINT_PORT = 10255 + + +class CosmosDBMongoEndpointContainer(CosmosDBEmulatorContainer): + """ + CosmosDB MongoDB enpoint Emulator. + + Example: + + .. code-block:: python + + >>> from testcontainers.cosmosdb import CosmosDBMongoEndpointContainer + + >>> with CosmosDBMongoEndpointContainer(mongodb_version="4.0") as emulator: + ... print(f"Point your MongoDB client at {emulator.host}:{emulator.port} using key {emulator.key}") + ... print(f"and eiher disable TLS server auth or trust the server's self signed cert (emulator.server_certificate_pem)") + + """ + + def __init__( + self, + mongodb_version: str, + image: str = os.getenv( + "AZURE_COSMOS_EMULATOR_IMAGE", "mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:mongodb" + ), + **other_kwargs, + ): + super().__init__(image=image, endpoint_ports=[ENDPOINT_PORT], **other_kwargs) + assert mongodb_version is not None, "A MongoDB version is required to use the MongoDB Endpoint" + self.mongodb_version = mongodb_version + + @property + def port(self) -> str: + """ + The exposed port to the MongoDB endpoint + """ + return self.get_exposed_port(ENDPOINT_PORT) + + def _configure(self) -> None: + super()._configure() + self.with_env("AZURE_COSMOS_EMULATOR_ENABLE_MONGODB_ENDPOINT", self.mongodb_version) diff --git a/modules/cosmosdb/testcontainers/cosmosdb/nosql.py b/modules/cosmosdb/testcontainers/cosmosdb/nosql.py new file mode 100644 index 00000000..f7846967 --- /dev/null +++ b/modules/cosmosdb/testcontainers/cosmosdb/nosql.py @@ -0,0 +1,69 @@ +from azure.core.exceptions import ServiceRequestError +from azure.cosmos import CosmosClient as SyncCosmosClient +from azure.cosmos.aio import CosmosClient as AsyncCosmosClient + +from testcontainers.core.waiting_utils import wait_container_is_ready + +from ._emulator import CosmosDBEmulatorContainer + +__all__ = ["CosmosDBNoSQLEndpointContainer"] + +NOSQL_PORT = 8081 + + +class CosmosDBNoSQLEndpointContainer(CosmosDBEmulatorContainer): + """ + CosmosDB NoSQL enpoint Emulator. + + Example: + + .. code-block:: python + + >>> from testcontainers.cosmosdb import CosmosDBNoSQLEndpointContainer + >>> with CosmosDBNoSQLEndpointContainer() as emulator: + ... db = emulator.insecure_sync_client().create_database_if_not_exists("test") + + .. code-block:: python + + >>> from testcontainers.cosmosdb import CosmosDBNoSQLEndpointContainer + >>> from azure.cosmos import CosmosClient + + >>> with CosmosDBNoSQLEndpointContainer() as emulator: + ... client = CosmosClient(url=emulator.url, credential=emulator.key, connection_verify=False) + ... db = client.create_database_if_not_exists("test") + + """ + + def __init__(self, **kwargs): + super().__init__(endpoint_ports=[NOSQL_PORT], **kwargs) + + @property + def port(self) -> str: + """ + The exposed port to the NoSQL endpoint + """ + return self.get_exposed_port(NOSQL_PORT) + + @property + def url(self) -> str: + """ + The url to the NoSQL endpoint + """ + return f"https://{self.host}:{self.port}" + + def insecure_async_client(self): + """ + Returns an asynchronous CosmosClient instance + """ + return AsyncCosmosClient(url=self.url, credential=self.key, connection_verify=False) + + def insecure_sync_client(self): + """ + Returns a synchronous CosmosClient instance + """ + return SyncCosmosClient(url=self.url, credential=self.key, connection_verify=False) + + @wait_container_is_ready(ServiceRequestError) + def _wait_for_query_success(self) -> None: + with self.insecure_sync_client() as c: + list(c.list_databases()) diff --git a/modules/cosmosdb/tests/test_emulator.py b/modules/cosmosdb/tests/test_emulator.py new file mode 100644 index 00000000..542ddd11 --- /dev/null +++ b/modules/cosmosdb/tests/test_emulator.py @@ -0,0 +1,8 @@ +import pytest +from testcontainers.cosmosdb._emulator import CosmosDBEmulatorContainer + + +def test_runs(): + with CosmosDBEmulatorContainer(partition_count=1, bind_ports=False) as emulator: + assert emulator.server_certificate_pem is not None + assert emulator.get_exposed_port(8081) is not None diff --git a/modules/cosmosdb/tests/test_mongodb.py b/modules/cosmosdb/tests/test_mongodb.py new file mode 100644 index 00000000..a50ee82e --- /dev/null +++ b/modules/cosmosdb/tests/test_mongodb.py @@ -0,0 +1,16 @@ +import pytest +from testcontainers.cosmosdb import CosmosDBMongoEndpointContainer + + +def test_requires_a_version(): + with pytest.raises(AssertionError, match="A MongoDB version is required"): + CosmosDBMongoEndpointContainer(mongodb_version=None) + + # instanciates + CosmosDBMongoEndpointContainer(mongodb_version="4.0") + + +def test_runs(): + with CosmosDBMongoEndpointContainer(mongodb_version="4.0", partition_count=1, bind_ports=False) as emulator: + assert emulator.env["AZURE_COSMOS_EMULATOR_ENABLE_MONGODB_ENDPOINT"] == "4.0" + assert emulator.get_exposed_port(10255) is not None, "The MongoDB endpoint's port should be exposed" diff --git a/modules/cosmosdb/tests/test_nosql.py b/modules/cosmosdb/tests/test_nosql.py new file mode 100644 index 00000000..a9460a1b --- /dev/null +++ b/modules/cosmosdb/tests/test_nosql.py @@ -0,0 +1,7 @@ +import pytest +from testcontainers.cosmosdb import CosmosDBNoSQLEndpointContainer + + +def test_runs(): + with CosmosDBNoSQLEndpointContainer(partition_count=1, bind_ports=False) as emulator: + assert emulator.get_exposed_port(8081) is not None, "The NoSQL endpoint's port should be exposed" diff --git a/poetry.lock b/poetry.lock index aa5fdc29..90a83f33 100644 --- a/poetry.lock +++ b/poetry.lock @@ -175,6 +175,21 @@ typing-extensions = ">=4.6.0" [package.extras] aio = ["aiohttp (>=3.0)"] +[[package]] +name = "azure-cosmos" +version = "4.7.0" +description = "Microsoft Azure Cosmos Client Library for Python" +optional = true +python-versions = ">=3.8" +files = [ + {file = "azure-cosmos-4.7.0.tar.gz", hash = "sha256:72d714033134656302a2e8957c4b93590673bd288b0ca60cb123e348ae99a241"}, + {file = "azure_cosmos-4.7.0-py3-none-any.whl", hash = "sha256:03d8c7740ddc2906fb16e07b136acc0fe6a6a02656db46c5dd6f1b127b58cc96"}, +] + +[package.dependencies] +azure-core = ">=1.25.1" +typing-extensions = ">=4.6.0" + [[package]] name = "azure-storage-blob" version = "12.19.1" @@ -4462,6 +4477,7 @@ cassandra = [] chroma = ["chromadb-client"] clickhouse = ["clickhouse-driver"] cockroachdb = [] +cosmosdb = ["azure-cosmos"] elasticsearch = [] generic = ["httpx"] google = ["google-cloud-datastore", "google-cloud-pubsub"] @@ -4497,4 +4513,4 @@ weaviate = ["weaviate-client"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<4.0" -content-hash = "e07f8edf8cefba872bbf48dcfa187163cefb00a60122daa62de8891b61fc55de" +content-hash = "2b87af7b69af2cc83f8198ab0fcfef7ceaf8411a8300c4ca72c0521e5d966445" diff --git a/pyproject.toml b/pyproject.toml index 0b608895..c7a398d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ packages = [ { include = "testcontainers", from = "modules/chroma" }, { include = "testcontainers", from = "modules/clickhouse" }, { include = "testcontainers", from = "modules/cockroachdb" }, + { include = "testcontainers", from = "modules/cosmosdb" }, { include = "testcontainers", from = "modules/elasticsearch" }, { include = "testcontainers", from = "modules/generic" }, { include = "testcontainers", from = "modules/testmoduleimport"}, @@ -106,12 +107,14 @@ chromadb-client = { version = "*", optional = true } qdrant-client = { version = "*", optional = true } bcrypt = { version = "*", optional = true } httpx = { version = "*", optional = true } +azure-cosmos = { version = "*", optional = true } [tool.poetry.extras] arangodb = ["python-arango"] azurite = ["azure-storage-blob"] cassandra = [] clickhouse = ["clickhouse-driver"] +cosmosdb = ["azure-cosmos"] cockroachdb = [] elasticsearch = [] generic = ["httpx"]