diff --git a/modules/azurite/testcontainers/azurite/__init__.py b/modules/azurite/testcontainers/azurite/__init__.py index 6d088651b..f4e76d670 100644 --- a/modules/azurite/testcontainers/azurite/__init__.py +++ b/modules/azurite/testcontainers/azurite/__init__.py @@ -10,6 +10,7 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. +import enum import os import socket from typing import Optional @@ -19,6 +20,20 @@ from testcontainers.core.waiting_utils import wait_container_is_ready +class ConnectionStringType(enum.Enum): + """ + Enumeration for specifying the type of connection string to generate for Azurite. + + :cvar LOCALHOST: Represents a connection string for access from the host machine + where the tests are running. + :cvar NETWORK: Represents a connection string for access from another container + within the same Docker network as the Azurite container. + """ + + LOCALHOST = "localhost" + NETWORK = "network" + + class AzuriteContainer(DockerContainer): """ The example below spins up an Azurite container and @@ -73,7 +88,45 @@ def __init__( self.with_exposed_ports(blob_service_port, queue_service_port, table_service_port) self.with_env("AZURITE_ACCOUNTS", f"{self.account_name}:{self.account_key}") - def get_connection_string(self) -> str: + def get_connection_string( + self, connection_string_type: ConnectionStringType = ConnectionStringType.LOCALHOST + ) -> str: + """Retrieves the appropriate connection string for the Azurite container based on the specified access type. + + This method acts as a dispatcher, returning a connection string optimized + either for access from the host machine or for inter-container communication within the same Docker network. + + :param connection_string_type: The type of connection string to generate. + Use :attr:`ConnectionStringType.LOCALHOST` for connections + from the machine running the tests (default), or + :attr:`ConnectionStringType.NETWORK` for connections + from other containers within the same Docker network. + :type connection_string_type: ConnectionStringType + :return: The generated Azurite connection string. + :rtype: str + :raises ValueError: If an unrecognized `connection_string_type` is provided. + """ + if connection_string_type == ConnectionStringType.LOCALHOST: + return self.__get_local_connection_string() + elif connection_string_type == ConnectionStringType.NETWORK: + return self.__get_external_connection_string() + else: + raise ValueError( + f"unrecognized connection string type {connection_string_type}, " + f"Supported values are ConnectionStringType.LOCALHOST or ConnectionStringType.NETWORK " + ) + + def __get_local_connection_string(self) -> str: + """Generates a connection string for Azurite accessible from the local host machine. + + This connection string uses the Docker host IP address (obtained via + :meth:`testcontainers.core.container.DockerContainer.get_container_host_ip`) + and the dynamically exposed ports of the Azurite container. This ensures that + clients running on the host can connect successfully to the Azurite services. + + :return: The Azurite connection string for local host access. + :rtype: str + """ host_ip = self.get_container_host_ip() connection_string = ( f"DefaultEndpointsProtocol=http;AccountName={self.account_name};AccountKey={self.account_key};" @@ -96,6 +149,75 @@ def get_connection_string(self) -> str: return connection_string + def __get_external_connection_string(self) -> str: + """Generates a connection string for Azurite, primarily optimized for + inter-container communication within a custom Docker network. + + This method attempts to provide the most suitable connection string + based on the container's network configuration: + + - **For Inter-Container Communication (Recommended):** If the Azurite container is + part of a custom Docker network and has network aliases configured, + the connection string will use the first network alias as the hostname + and the internal container ports (e.g., #$#`http://:/`#$#). + This is the most efficient and robust way for other containers + in the same network to connect to Azurite, leveraging Docker's internal DNS. + + - **Fallback for Non-Networked/Aliased Scenarios:** If the container is + not on a custom network with aliases (e.g., running on the default + bridge network without explicit aliases), the method falls back to + using the Docker host IP (obtained via + :meth:`testcontainers.core.container.DockerContainer.get_container_host_ip`) + and the dynamically exposed ports (e.g., #$#`http://:/`#$#). + While this connection string is technically "external" to the container, + it primarily facilitates connections *from the host machine*. + + :return: The generated Azurite connection string. + :rtype: str + """ + # Check if we're on a custom network and have network aliases + if hasattr(self, "_network") and self._network and hasattr(self, "_network_aliases") and self._network_aliases: + # Use the first network alias for inter-container communication + host_ip = self._network_aliases[0] + # When using network aliases, use the internal container ports + blob_port = self.blob_service_port + queue_port = self.queue_service_port + table_port = self.table_service_port + else: + # Use the Docker host IP for external connections + host_ip = self.get_container_host_ip() + # When using host IP, use the exposed ports + blob_port = ( + self.get_exposed_port(self.blob_service_port) + if self.blob_service_port in self.ports + else self.blob_service_port + ) + queue_port = ( + self.get_exposed_port(self.queue_service_port) + if self.queue_service_port in self.ports + else self.queue_service_port + ) + table_port = ( + self.get_exposed_port(self.table_service_port) + if self.table_service_port in self.ports + else self.table_service_port + ) + + connection_string = ( + f"DefaultEndpointsProtocol=http;AccountName={self.account_name};AccountKey={self.account_key};" + ) + + if self.blob_service_port in self.ports: + connection_string += f"BlobEndpoint=http://{host_ip}:{blob_port}/{self.account_name};" + + if self.queue_service_port in self.ports: + connection_string += f"QueueEndpoint=http://{host_ip}:{queue_port}/{self.account_name};" + + if self.table_service_port in self.ports: + connection_string += f"TableEndpoint=http://{host_ip}:{table_port}/{self.account_name};" + + return connection_string + def start(self) -> "AzuriteContainer": super().start() self._connect() diff --git a/modules/azurite/tests/samples/network_container/Dockerfile b/modules/azurite/tests/samples/network_container/Dockerfile new file mode 100644 index 000000000..7ee7a2675 --- /dev/null +++ b/modules/azurite/tests/samples/network_container/Dockerfile @@ -0,0 +1,12 @@ +# Use an official Python runtime as a parent image +FROM python:3.10-slim + +# Set the working directory in the container +WORKDIR /app + +RUN pip install azure-storage-blob==12.19.0 + +COPY ./netowrk_container.py netowrk_container.py +EXPOSE 80 +# Define the command to run the application +CMD ["python", "netowrk_container.py"] diff --git a/modules/azurite/tests/samples/network_container/netowrk_container.py b/modules/azurite/tests/samples/network_container/netowrk_container.py new file mode 100644 index 000000000..4831d4089 --- /dev/null +++ b/modules/azurite/tests/samples/network_container/netowrk_container.py @@ -0,0 +1,27 @@ +from azure.storage.blob import BlobClient, BlobServiceClient +import os + + +def hello_from_external_container(): + """ + Entry point function for a custom Docker container to test connectivity + and operations with Azurite (or Azure Blob Storage). + + This function is designed to run inside a separate container within the + same Docker network as an Azurite instance. It retrieves connection + details from environment variables and attempts to create a new + blob container on the connected storage account. + """ + connection_string = os.environ["AZURE_CONNECTION_STRING"] + container_to_create = os.environ["AZURE_CONTAINER"] + blob_service_client = BlobServiceClient.from_connection_string(connection_string) + # create dummy container just to make sure we can process the + try: + blob_service_client.create_container(name=container_to_create) + print("Azure Storage Container created.") + except Exception as e: + print(f"Something went wrong : {e}") + + +if __name__ == "__main__": + hello_from_external_container() diff --git a/modules/azurite/tests/test_azurite.py b/modules/azurite/tests/test_azurite.py index 74230ab14..2ec3c7502 100644 --- a/modules/azurite/tests/test_azurite.py +++ b/modules/azurite/tests/test_azurite.py @@ -1,6 +1,24 @@ +import logging +import time +from pathlib import Path + from azure.storage.blob import BlobServiceClient -from testcontainers.azurite import AzuriteContainer +from testcontainers.azurite import AzuriteContainer, ConnectionStringType + +from testcontainers.core.image import DockerImage +from testcontainers.core.container import DockerContainer +from testcontainers.core.network import Network +from testcontainers.core.waiting_utils import wait_for_logs + + +logger = logging.getLogger(__name__) + + +DOCKER_FILE_PATH = ".modules/azurite/tests/external_container_sample" +IMAGE_TAG = "external_container:test" + +TEST_DIR = Path(__file__).parent def test_docker_run_azurite(): @@ -10,3 +28,49 @@ def test_docker_run_azurite(): ) blob_service_client.create_container("test-container") + + +def test_docker_run_azurite_inter_container_communication(): + """Tests inter-container communication between an Azurite container and a custom + application container within the same Docker network, while also verifying + local machine access to Azurite. + + This test case validates the following: + 1. An Azurite container can be successfully started and configured with a + custom Docker network and a network alias. + 2. A custom application container can connect to the Azurite container + using a network-specific connection string (via its network alias) + within the shared Docker network. + 3. The Azurite container remains accessible from the local test machine + using a host-specific connection string. + 4. Operations performed by the custom container on Azurite (e.g., creating + a storage container) are visible and verifiable from the local machine. + """ + container_name = "test-container" + with Network() as network: + with ( + AzuriteContainer() + .with_network(network) + .with_network_aliases("azurite_server") + .with_exposed_ports(10000, 10000) + .with_exposed_ports(10001, 10001) as azurite_container + ): + network_connection_string = azurite_container.get_connection_string(ConnectionStringType.NETWORK) + local_connection_string = azurite_container.get_connection_string() + with DockerImage(path=TEST_DIR / "samples/network_container", tag=IMAGE_TAG) as image: + with ( + DockerContainer(image=str(image)) + .with_env("AZURE_CONNECTION_STRING", network_connection_string) + .with_env("AZURE_CONTAINER", container_name) + .with_network(network) + .with_network_aliases("network_container") + .with_exposed_ports(80, 80) as container + ): + wait_for_logs(container, "Azure Storage Container created.") + blob_service_client = BlobServiceClient.from_connection_string( + local_connection_string, api_version="2019-12-12" + ) + # make sure the container was actually created + assert container_name in [ + blob_container["name"] for blob_container in blob_service_client.list_containers() + ]