Skip to content

feat(azurite): Enhance connection string generation for network and local access #859

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 123 additions & 1 deletion modules/azurite/testcontainers/azurite/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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};"
Expand All @@ -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://<alias>:<internal_port>/<account_name>`#$#).
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://<host_ip>:<exposed_port>/<account_name>`#$#).
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()
Expand Down
12 changes: 12 additions & 0 deletions modules/azurite/tests/samples/network_container/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
Original file line number Diff line number Diff line change
@@ -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()
66 changes: 65 additions & 1 deletion modules/azurite/tests/test_azurite.py
Original file line number Diff line number Diff line change
@@ -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():
Expand All @@ -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()
]
Loading