Skip to content

Commit 8045a80

Browse files
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 <[email protected]>
1 parent e575b28 commit 8045a80

File tree

12 files changed

+333
-62
lines changed

12 files changed

+333
-62
lines changed

index.rst

Lines changed: 21 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ testcontainers-python
1313
testcontainers-python facilitates the use of Docker containers for functional and integration testing. The collection of packages currently supports the following features.
1414

1515
.. toctree::
16-
:maxdepth: 1
1716

1817
core/README
1918
modules/index
@@ -60,15 +59,12 @@ Installation
6059
------------
6160

6261
The suite of testcontainers packages is available on `PyPI <https://pypi.org/project/testcontainers/>`_,
63-
and the package can be installed using :code:`pip`.
62+
and individual packages can be installed using :code:`pip`.
6463

65-
Version `4.0.0` onwards we do not support the `testcontainers-*` packages as it is unsustainable to maintain ownership.
64+
Version `4.0.0` onwards we do not support the `testcontainers-*` packages as it is unsutainable to maintain ownership.
6665

6766
Instead packages can be installed by specifying `extras <https://setuptools.readthedocs.io/en/latest/setuptools.html#declaring-extras-optional-features-with-their-own-dependencies>`__, e.g., :code:`pip install testcontainers[postgres]`.
6867

69-
Please note, that community modules are supported on a best-effort basis and breaking changes DO NOT create major versions in the package.
70-
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.
71-
7268

7369
Custom Containers
7470
-----------------
@@ -84,75 +80,40 @@ For common use cases, you can also use the generic containers provided by the `t
8480
Docker in Docker (DinD)
8581
-----------------------
8682

87-
When trying to launch Testcontainers from within a Docker container, e.g., in continuous integration testing, two things have to be provided:
83+
When trying to launch a testcontainer from within a Docker container, e.g., in continuous integration testing, two things have to be provided:
8884

8985
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 <https://hub.docker.com/_/docker>`_) or install the client from within the `Dockerfile` specification.
9086
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.
9187

92-
Private Docker registry
93-
-----------------------
94-
95-
Using a private docker registry requires the `DOCKER_AUTH_CONFIG` environment variable to be set.
96-
`official documentation <https://docs.docker.com/engine/reference/commandline/login/#credential-helpers>`_
97-
98-
The value of this variable should be a JSON string containing the authentication information for the registry.
99-
100-
Example:
101-
102-
.. code-block:: bash
103-
104-
DOCKER_AUTH_CONFIG='{"auths": {"https://myregistry.com": {"auth": "dXNlcm5hbWU6cGFzc3dvcmQ="}}}'
105-
106-
In order to generate the JSON string, you can use the following command:
107-
108-
.. code-block:: bash
109-
110-
echo -n '{"auths": {"<url>": {"auth": "'$(echo -n "<username>:<password>" | base64 -w 0)'"}}}'
111-
112-
Fetching passwords from cloud providers:
113-
114-
.. code-block:: bash
115-
116-
ECR_PASSWORD = $(aws ecr get-login-password --region eu-west-1)
117-
GCP_PASSWORD = $(gcloud auth print-access-token)
118-
AZURE_PASSWORD = $(az acr login --name <registry-name> --expose-token --output tsv)
119-
120-
12188
Configuration
12289
-------------
12390

124-
+-------------------------------------------+---------------------------------------------------+------------------------------------------+
125-
| Env Variable | Example | Description |
126-
+===========================================+===================================================+==========================================+
127-
| ``TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE`` | ``/var/run/docker.sock`` | Path to Docker's socket used by ryuk |
128-
+-------------------------------------------+---------------------------------------------------+------------------------------------------+
129-
| ``TESTCONTAINERS_RYUK_PRIVILEGED`` | ``false`` | Run ryuk as a privileged container |
130-
+-------------------------------------------+---------------------------------------------------+------------------------------------------+
131-
| ``TESTCONTAINERS_RYUK_DISABLED`` | ``false`` | Disable ryuk |
132-
+-------------------------------------------+---------------------------------------------------+------------------------------------------+
133-
| ``RYUK_CONTAINER_IMAGE`` | ``testcontainers/ryuk:0.7.0`` | Custom image for ryuk |
134-
+-------------------------------------------+---------------------------------------------------+------------------------------------------+
135-
| ``DOCKER_AUTH_CONFIG`` | ``{"auths": {"<url>": {"auth": "<encoded>"}}}`` | Custom registry auth config |
136-
+-------------------------------------------+---------------------------------------------------+------------------------------------------+
91+
+-------------------------------------------+-------------------------------+------------------------------------------+
92+
| Env Variable | Example | Description |
93+
+===========================================+===============================+==========================================+
94+
| ``TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE`` | ``/var/run/docker.sock`` | Path to Docker's socket used by ryuk |
95+
+-------------------------------------------+-------------------------------+------------------------------------------+
96+
| ``TESTCONTAINERS_RYUK_PRIVILEGED`` | ``false`` | Run ryuk as a privileged container |
97+
+-------------------------------------------+-------------------------------+------------------------------------------+
98+
| ``TESTCONTAINERS_RYUK_DISABLED`` | ``false`` | Disable ryuk |
99+
+-------------------------------------------+-------------------------------+------------------------------------------+
100+
| ``RYUK_CONTAINER_IMAGE`` | ``testcontainers/ryuk:0.7.0`` | Custom image for ryuk |
101+
+-------------------------------------------+-------------------------------+------------------------------------------+
137102

138103
Development and Contributing
139104
----------------------------
140105

141-
We recommend you use a `Poetry <https://python-poetry.org/docs/>`_ for development.
142-
After having installed `poetry`, you can run the following snippet to set up your local dev environment.
106+
We recommend you use a `virtual environment <https://virtualenv.pypa.io/en/stable/>`_ 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.
143107

144108
.. code-block:: bash
145109
146-
make install
110+
poetry install --all-extras
111+
make <your-module>/tests
147112
148113
Package Structure
149114
^^^^^^^^^^^^^^^^^
150115

151-
Testcontainers is a collection of `implicit namespace packages <https://peps.python.org/pep-0420/>`__
152-
to decouple the development of different extensions,
153-
e.g., :code:`testcontainers[mysql]` and :code:`testcontainers[postgres]` for MySQL and PostgreSQL database containers, respectively.
154-
155-
The folder structure is as follows:
116+
Testcontainers is a collection of `implicit namespace packages <https://peps.python.org/pep-0420/>`__ 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.
156117

157118
.. code-block:: bash
158119
@@ -172,11 +133,10 @@ The folder structure is as follows:
172133
...
173134
# README for this feature.
174135
README.rst
136+
# Setup script for this feature.
137+
setup.py
175138
176139
Contributing a New Feature
177140
^^^^^^^^^^^^^^^^^^^^^^^^^^
178141

179-
You want to contribute a new feature or container? Great!
180-
- We recommend you first `open an issue <https://github.com/testcontainers/testcontainers-python/issues/new/choose>`_
181-
- Then follow the suggestions from the team
182-
- We also have a Pull Request `template <https://github.com/testcontainers/testcontainers-python/blob/main/.github/PULL_REQUEST_TEMPLATE/new_container.md>`_ for new containers!
142+
You want to contribute a new feature or container? Great! You can do that in six steps as outlined `here <https://github.com/testcontainers/testcontainers-python/blob/main/.github/PULL_REQUEST_TEMPLATE/new_container.md>__`.

modules/cosmosdb/README.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
.. autoclass:: testcontainers.cosmosdb.CosmosDBMongoEndpointContainer
2+
.. title:: testcontainers.cosmosdb.CosmosDBMongoEndpointContainer
3+
4+
.. autoclass:: testcontainers.cosmosdb.CosmosDBNoSQLEndpointContainer
5+
.. title:: testcontainers.cosmosdb.CosmosDBNoSQLEndpointContainer
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .mongodb import CosmosDBMongoEndpointContainer
2+
from .nosql import CosmosDBNoSQLEndpointContainer
3+
4+
__all__ = ["CosmosDBMongoEndpointContainer", "CosmosDBNoSQLEndpointContainer"]
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import os
2+
import socket
3+
import ssl
4+
from collections.abc import Iterable
5+
from distutils.util import strtobool
6+
from urllib.error import HTTPError, URLError
7+
from urllib.request import urlopen
8+
9+
from typing_extensions import Self
10+
11+
from testcontainers.core.container import DockerContainer
12+
from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs
13+
14+
from . import _grab as grab
15+
16+
__all__ = ["CosmosDBEmulatorContainer"]
17+
18+
EMULATOR_PORT = 8081
19+
20+
21+
class CosmosDBEmulatorContainer(DockerContainer):
22+
"""
23+
Abstract class for CosmosDB Emulator endpoints.
24+
25+
Concrete implementations for each endpoint is provided by a separate class:
26+
NoSQLEmulatorContainer and MongoDBEmulatorContainer.
27+
"""
28+
29+
def __init__(
30+
self,
31+
image: str = os.getenv(
32+
"AZURE_COSMOS_EMULATOR_IMAGE", "mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest"
33+
),
34+
partition_count: int = os.getenv("AZURE_COSMOS_EMULATOR_PARTITION_COUNT", None),
35+
enable_data_persistence: bool = strtobool(os.getenv("AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE", "false")),
36+
key: str = os.getenv(
37+
"AZURE_COSMOS_EMULATOR_KEY",
38+
"C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==",
39+
),
40+
bind_ports: bool = strtobool(os.getenv("AZURE_COSMOS_EMULATOR_BIND_PORTS", "true")),
41+
endpoint_ports: Iterable[int] = [],
42+
**other_kwargs,
43+
):
44+
super().__init__(image=image, **other_kwargs)
45+
self.endpoint_ports = endpoint_ports
46+
self.partition_count = partition_count
47+
self.key = key
48+
self.enable_data_persistence = enable_data_persistence
49+
self.bind_ports = bind_ports
50+
51+
@property
52+
def host(self) -> str:
53+
"""
54+
Emulator host
55+
"""
56+
return self.get_container_host_ip()
57+
58+
@property
59+
def server_certificate_pem(self) -> bytes:
60+
"""
61+
PEM-encoded server certificate
62+
"""
63+
return self._cert_pem_bytes
64+
65+
def start(self) -> Self:
66+
self._configure()
67+
super().start()
68+
self._wait_until_ready()
69+
self._cert_pem_bytes = self._download_cert()
70+
return self
71+
72+
def _configure(self) -> None:
73+
all_ports = {EMULATOR_PORT, *self.endpoint_ports}
74+
if self.bind_ports:
75+
for port in all_ports:
76+
self.with_bind_ports(port, port)
77+
else:
78+
self.with_exposed_ports(*all_ports)
79+
80+
(
81+
self.with_env("AZURE_COSMOS_EMULATOR_PARTITION_COUNT", str(self.partition_count))
82+
.with_env("AZURE_COSMOS_EMULATOR_IP_ADDRESS_OVERRIDE", socket.gethostbyname(socket.gethostname()))
83+
.with_env("AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE", str(self.enable_data_persistence))
84+
.with_env("AZURE_COSMOS_EMULATOR_KEY", str(self.key))
85+
)
86+
87+
def _wait_until_ready(self) -> Self:
88+
wait_for_logs(container=self, predicate="Started\\s*$")
89+
90+
if self.bind_ports:
91+
self._wait_for_url(f"https://{self.host}:{EMULATOR_PORT}/_explorer/index.html")
92+
self._wait_for_query_success()
93+
94+
return self
95+
96+
def _download_cert(self) -> bytes:
97+
with grab.file(
98+
self.get_wrapped_container(),
99+
"/tmp/cosmos/appdata/.system/profiles/Client/AppData/Local/CosmosDBEmulator/emulator.pem",
100+
) as cert:
101+
return cert.read()
102+
103+
@wait_container_is_ready(HTTPError, URLError)
104+
def _wait_for_url(self, url: str) -> Self:
105+
with urlopen(url, context=ssl._create_unverified_context()) as response:
106+
response.read()
107+
return self
108+
109+
def _wait_for_query_success(self) -> None:
110+
pass
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import tarfile
2+
import tempfile
3+
from contextlib import contextmanager
4+
from os import path
5+
from pathlib import Path
6+
7+
from docker.models.containers import Container
8+
9+
10+
@contextmanager
11+
def file(container: Container, target: str):
12+
target_path = Path(target)
13+
assert target_path.is_absolute(), "target must be an absolute path"
14+
15+
with tempfile.TemporaryDirectory() as tmp:
16+
archive = Path(tmp) / "grabbed.tar"
17+
18+
# download from container as tar archive
19+
with open(archive, "wb") as f:
20+
tar_bits, _ = container.get_archive(target)
21+
for chunk in tar_bits:
22+
f.write(chunk)
23+
24+
# extract target file from tar archive
25+
with tarfile.TarFile(archive) as tar:
26+
yield tar.extractfile(path.basename(target))
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import os
2+
3+
from ._emulator import CosmosDBEmulatorContainer
4+
5+
__all__ = ["CosmosDBMongoEndpointContainer"]
6+
7+
ENDPOINT_PORT = 10255
8+
9+
10+
class CosmosDBMongoEndpointContainer(CosmosDBEmulatorContainer):
11+
"""
12+
CosmosDB MongoDB enpoint Emulator.
13+
14+
Example:
15+
16+
.. code-block:: python
17+
18+
>>> from testcontainers.cosmosdb import CosmosDBMongoEndpointContainer
19+
20+
>>> with CosmosDBMongoEndpointContainer(mongodb_version="4.0") as emulator:
21+
... print(f"Point your MongoDB client at {emulator.host}:{emulator.port} using key {emulator.key}")
22+
... print(f"and eiher disable TLS server auth or trust the server's self signed cert (emulator.server_certificate_pem)")
23+
24+
"""
25+
26+
def __init__(
27+
self,
28+
mongodb_version: str,
29+
image: str = os.getenv(
30+
"AZURE_COSMOS_EMULATOR_IMAGE", "mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:mongodb"
31+
),
32+
**other_kwargs,
33+
):
34+
super().__init__(image=image, endpoint_ports=[ENDPOINT_PORT], **other_kwargs)
35+
assert mongodb_version is not None, "A MongoDB version is required to use the MongoDB Endpoint"
36+
self.mongodb_version = mongodb_version
37+
38+
@property
39+
def port(self) -> str:
40+
"""
41+
The exposed port to the MongoDB endpoint
42+
"""
43+
return self.get_exposed_port(ENDPOINT_PORT)
44+
45+
def _configure(self) -> None:
46+
super()._configure()
47+
self.with_env("AZURE_COSMOS_EMULATOR_ENABLE_MONGODB_ENDPOINT", self.mongodb_version)
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from azure.core.exceptions import ServiceRequestError
2+
from azure.cosmos import CosmosClient as SyncCosmosClient
3+
from azure.cosmos.aio import CosmosClient as AsyncCosmosClient
4+
5+
from testcontainers.core.waiting_utils import wait_container_is_ready
6+
7+
from ._emulator import CosmosDBEmulatorContainer
8+
9+
__all__ = ["CosmosDBNoSQLEndpointContainer"]
10+
11+
NOSQL_PORT = 8081
12+
13+
14+
class CosmosDBNoSQLEndpointContainer(CosmosDBEmulatorContainer):
15+
"""
16+
CosmosDB NoSQL enpoint Emulator.
17+
18+
Example:
19+
20+
.. code-block:: python
21+
22+
>>> from testcontainers.cosmosdb import CosmosDBNoSQLEndpointContainer
23+
>>> with CosmosDBNoSQLEndpointContainer() as emulator:
24+
... db = emulator.insecure_sync_client().create_database_if_not_exists("test")
25+
26+
.. code-block:: python
27+
28+
>>> from testcontainers.cosmosdb import CosmosDBNoSQLEndpointContainer
29+
>>> from azure.cosmos import CosmosClient
30+
31+
>>> with CosmosDBNoSQLEndpointContainer() as emulator:
32+
... client = CosmosClient(url=emulator.url, credential=emulator.key, connection_verify=False)
33+
... db = client.create_database_if_not_exists("test")
34+
35+
"""
36+
37+
def __init__(self, **kwargs):
38+
super().__init__(endpoint_ports=[NOSQL_PORT], **kwargs)
39+
40+
@property
41+
def port(self) -> str:
42+
"""
43+
The exposed port to the NoSQL endpoint
44+
"""
45+
return self.get_exposed_port(NOSQL_PORT)
46+
47+
@property
48+
def url(self) -> str:
49+
"""
50+
The url to the NoSQL endpoint
51+
"""
52+
return f"https://{self.host}:{self.port}"
53+
54+
def insecure_async_client(self):
55+
"""
56+
Returns an asynchronous CosmosClient instance
57+
"""
58+
return AsyncCosmosClient(url=self.url, credential=self.key, connection_verify=False)
59+
60+
def insecure_sync_client(self):
61+
"""
62+
Returns a synchronous CosmosClient instance
63+
"""
64+
return SyncCosmosClient(url=self.url, credential=self.key, connection_verify=False)
65+
66+
@wait_container_is_ready(ServiceRequestError)
67+
def _wait_for_query_success(self) -> None:
68+
with self.insecure_sync_client() as c:
69+
list(c.list_databases())

0 commit comments

Comments
 (0)