Skip to content

Commit 28295e2

Browse files
whummerclaude
andcommitted
Refactor to use ProxiedDockerContainerExtension with better abstractions
- Add env_vars and health_check_fn parameters to ProxiedDockerContainerExtension - Refactor ParadeDbExtension to use shared base class - Improve integration tests to use ProxiedDockerContainerExtension - Remove weak test assertions and fix flaky tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 426e040 commit 28295e2

File tree

7 files changed

+155
-143
lines changed

7 files changed

+155
-143
lines changed

paradedb/localstack_paradedb/extension.py

Lines changed: 12 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,6 @@
44

55
from localstack_extensions.utils.docker import ProxiedDockerContainerExtension
66
from localstack.extensions.api import http
7-
from localstack.utils.docker_utils import DOCKER_CLIENT
8-
from localstack.utils.container_utils.container_client import PortMappings
9-
from localstack.utils.sync import retry
107
from werkzeug.datastructures import Headers
118

129
LOG = logging.getLogger(__name__)
@@ -39,22 +36,28 @@ def __init__(self):
3936
postgres_db = os.environ.get(ENV_POSTGRES_DB, DEFAULT_POSTGRES_DB)
4037
postgres_port = int(os.environ.get(ENV_POSTGRES_PORT, DEFAULT_POSTGRES_PORT))
4138

39+
# Store configuration for connection info
40+
self.postgres_user = postgres_user
41+
self.postgres_password = postgres_password
42+
self.postgres_db = postgres_db
43+
self.postgres_port = postgres_port
44+
4245
# Environment variables to pass to the container
43-
self.env_vars = {
46+
env_vars = {
4447
"POSTGRES_USER": postgres_user,
4548
"POSTGRES_PASSWORD": postgres_password,
4649
"POSTGRES_DB": postgres_db,
4750
}
4851

49-
# Store configuration for connection info
50-
self.postgres_user = postgres_user
51-
self.postgres_password = postgres_password
52-
self.postgres_db = postgres_db
53-
self.postgres_port = postgres_port
52+
def _tcp_health_check():
53+
"""Check if PostgreSQL port is accepting connections."""
54+
self._check_tcp_port(self.container_host, self.postgres_port)
5455

5556
super().__init__(
5657
image_name=self.DOCKER_IMAGE,
5758
container_ports=[postgres_port],
59+
env_vars=env_vars,
60+
health_check_fn=_tcp_health_check,
5861
)
5962

6063
def should_proxy_request(self, headers: Headers) -> bool:
@@ -71,45 +74,6 @@ def update_gateway_routes(self, router: http.Router[http.RouteHandler]):
7174
"""
7275
self.start_container()
7376

74-
def start_container(self) -> None:
75-
"""Override to add env_vars support and database-specific health checking."""
76-
LOG.debug("Starting extension container %s", self.container_name)
77-
78-
port_mapping = PortMappings()
79-
for port in self.container_ports:
80-
port_mapping.add(port)
81-
82-
try:
83-
DOCKER_CLIENT.run_container(
84-
self.image_name,
85-
detach=True,
86-
remove=True,
87-
name=self.container_name,
88-
ports=port_mapping,
89-
env_vars=self.env_vars,
90-
)
91-
except Exception as e:
92-
LOG.debug("Failed to start container %s: %s", self.container_name, e)
93-
raise
94-
95-
def _check_health():
96-
"""Check if PostgreSQL port is accepting connections."""
97-
self._check_tcp_port(self.container_host, self.postgres_port)
98-
99-
try:
100-
retry(_check_health, retries=60, sleep=1)
101-
except Exception as e:
102-
LOG.info("Failed to connect to container %s: %s", self.container_name, e)
103-
self._remove_container()
104-
raise
105-
106-
LOG.info(
107-
"Successfully started extension container %s on %s:%s",
108-
self.container_name,
109-
self.container_host,
110-
self.postgres_port,
111-
)
112-
11377
def _check_tcp_port(self, host: str, port: int, timeout: float = 2.0) -> None:
11478
"""Check if a TCP port is accepting connections."""
11579
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

utils/localstack_extensions/utils/docker.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ class ProxiedDockerContainerExtension(Extension):
4747
"""Optional path on which to expose the container endpoints."""
4848
command: list[str] | None
4949
"""Optional command (and flags) to execute in the container."""
50+
env_vars: dict[str, str] | None
51+
"""Optional environment variables to pass to the container."""
52+
health_check_fn: Callable[[], None] | None
53+
"""
54+
Optional custom health check function. If not provided, defaults to HTTP GET on main_port.
55+
The function should raise an exception if the health check fails.
56+
"""
5057

5158
request_to_port_router: Callable[[Request], int] | None
5259
"""Callable that returns the target port for a given request, for routing purposes"""
@@ -60,6 +67,8 @@ def __init__(
6067
host: str | None = None,
6168
path: str | None = None,
6269
command: list[str] | None = None,
70+
env_vars: dict[str, str] | None = None,
71+
health_check_fn: Callable[[], None] | None = None,
6372
request_to_port_router: Callable[[Request], int] | None = None,
6473
http2_ports: list[int] | None = None,
6574
):
@@ -71,6 +80,8 @@ def __init__(
7180
self.path = path
7281
self.container_name = re.sub(r"\W", "-", f"ls-ext-{self.name}")
7382
self.command = command
83+
self.env_vars = env_vars
84+
self.health_check_fn = health_check_fn
7485
self.request_to_port_router = request_to_port_router
7586
self.http2_ports = http2_ports
7687
self.main_port = self.container_ports[0]
@@ -113,6 +124,8 @@ def start_container(self) -> None:
113124
kwargs = {}
114125
if self.command:
115126
kwargs["command"] = self.command
127+
if self.env_vars:
128+
kwargs["env_vars"] = self.env_vars
116129

117130
try:
118131
DOCKER_CLIENT.run_container(
@@ -129,20 +142,23 @@ def start_container(self) -> None:
129142
if not is_env_true(f"{self.name.upper().replace('-', '_')}_DEV_MODE"):
130143
raise
131144

132-
def _ping_endpoint():
133-
# TODO: allow defining a custom healthcheck endpoint ...
134-
response = requests.get(f"http://{self.container_host}:{self.main_port}/")
135-
assert response.ok
145+
# Use custom health check if provided, otherwise default to HTTP GET
146+
health_check = self.health_check_fn or self._default_health_check
136147

137148
try:
138-
retry(_ping_endpoint, retries=40, sleep=1)
149+
retry(health_check, retries=60, sleep=1)
139150
except Exception as e:
140151
LOG.info("Failed to connect to container %s: %s", self.container_name, e)
141152
self._remove_container()
142153
raise
143154

144155
LOG.debug("Successfully started extension container %s", self.container_name)
145156

157+
def _default_health_check(self) -> None:
158+
"""Default health check: HTTP GET request to the main port."""
159+
response = requests.get(f"http://{self.container_host}:{self.main_port}/")
160+
assert response.ok
161+
146162
def _remove_container(self):
147163
LOG.debug("Stopping extension container %s", self.container_name)
148164
DOCKER_CLIENT.remove_container(

utils/tests/integration/conftest.py

Lines changed: 63 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -3,99 +3,93 @@
33
44
Provides fixtures for running tests against the grpcbin Docker container.
55
grpcbin is a neutral gRPC test service that supports various RPC types.
6+
7+
Uses ProxiedDockerContainerExtension to manage the grpcbin container,
8+
providing realistic test coverage of the Docker container management infrastructure.
69
"""
710

8-
import time
11+
import socket
912
import pytest
1013

11-
from localstack.utils.container_utils.container_client import PortMappings
12-
from localstack.utils.docker_utils import DOCKER_CLIENT
13-
from localstack.utils.net import wait_for_port_open
14+
from werkzeug.datastructures import Headers
15+
from localstack_extensions.utils.docker import ProxiedDockerContainerExtension
1416

1517

1618
GRPCBIN_IMAGE = "moul/grpcbin"
1719
GRPCBIN_INSECURE_PORT = 9000 # HTTP/2 without TLS
1820
GRPCBIN_SECURE_PORT = 9001 # HTTP/2 with TLS
1921

2022

21-
@pytest.fixture(scope="session")
22-
def grpcbin_container():
23+
class GrpcbinExtension(ProxiedDockerContainerExtension):
24+
"""
25+
Test extension for grpcbin that uses ProxiedDockerContainerExtension.
26+
27+
This extension demonstrates using ProxiedDockerContainerExtension for
28+
a gRPC/HTTP2 service. While grpcbin doesn't use the HTTP gateway routing
29+
(it's accessed via direct TCP), this tests the Docker container management
30+
capabilities of ProxiedDockerContainerExtension.
2331
"""
24-
Start a grpcbin Docker container for testing.
2532

26-
The container exposes:
27-
- Port 9000: Insecure gRPC (HTTP/2 without TLS)
28-
- Port 9001: Secure gRPC (HTTP/2 with TLS)
33+
name = "grpcbin-test"
34+
35+
def __init__(self):
36+
def _tcp_health_check():
37+
"""Check if grpcbin insecure port is accepting TCP connections."""
38+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
39+
sock.settimeout(2.0)
40+
try:
41+
# Use container_host from the parent class
42+
sock.connect((self.container_host, GRPCBIN_INSECURE_PORT))
43+
sock.close()
44+
except (socket.timeout, socket.error) as e:
45+
raise AssertionError(f"Port {GRPCBIN_INSECURE_PORT} not ready: {e}")
46+
47+
super().__init__(
48+
image_name=GRPCBIN_IMAGE,
49+
container_ports=[GRPCBIN_INSECURE_PORT, GRPCBIN_SECURE_PORT],
50+
health_check_fn=_tcp_health_check,
51+
)
52+
53+
def should_proxy_request(self, headers: Headers) -> bool:
54+
"""
55+
gRPC services use direct TCP connections, not HTTP gateway routing.
56+
This method is not used in these tests but is required by the base class.
57+
"""
58+
return False
59+
2960

30-
The container is automatically removed after tests complete.
61+
@pytest.fixture(scope="session")
62+
def grpcbin_extension():
3163
"""
32-
container_name = "pytest-grpcbin"
33-
34-
# Remove any existing container with the same name
35-
try:
36-
DOCKER_CLIENT.remove_container(container_name)
37-
except Exception:
38-
pass # Container may not exist
39-
40-
# Configure port mappings
41-
ports = PortMappings()
42-
ports.add(GRPCBIN_INSECURE_PORT)
43-
ports.add(GRPCBIN_SECURE_PORT)
44-
45-
# Start the container
46-
stdout, _ = DOCKER_CLIENT.run_container(
47-
image_name=GRPCBIN_IMAGE,
48-
name=container_name,
49-
detach=True,
50-
remove=True,
51-
ports=ports,
52-
)
53-
container_id = stdout.decode().strip()
54-
55-
# Wait for the insecure port to be ready with enough retries
56-
try:
57-
wait_for_port_open(GRPCBIN_INSECURE_PORT, retries=60, sleep_time=0.5)
58-
except Exception:
59-
# Clean up and fail
60-
try:
61-
DOCKER_CLIENT.remove_container(container_name)
62-
except Exception:
63-
pass
64-
pytest.fail(f"grpcbin port {GRPCBIN_INSECURE_PORT} did not become available")
65-
66-
# Brief delay to allow grpcbin HTTP/2 server to fully initialize
67-
# (port open doesn't guarantee HTTP/2 readiness)
68-
time.sleep(0.5)
69-
70-
# Provide connection info to tests
71-
yield {
72-
"container_id": container_id,
73-
"container_name": container_name,
74-
"host": "localhost",
75-
"insecure_port": GRPCBIN_INSECURE_PORT,
76-
"secure_port": GRPCBIN_SECURE_PORT,
77-
}
78-
79-
# Cleanup: stop and remove the container
80-
try:
81-
DOCKER_CLIENT.remove_container(container_name)
82-
except Exception:
83-
pass
64+
Start grpcbin using ProxiedDockerContainerExtension.
65+
66+
This tests the Docker container management capabilities while providing
67+
a realistic gRPC/HTTP2 test service for integration tests.
68+
"""
69+
extension = GrpcbinExtension()
70+
71+
# Start the container using the extension infrastructure
72+
extension.start_container()
73+
74+
yield extension
75+
76+
# Cleanup
77+
extension.on_platform_shutdown()
8478

8579

8680
@pytest.fixture
87-
def grpcbin_host(grpcbin_container):
81+
def grpcbin_host(grpcbin_extension):
8882
"""Return the host address for the grpcbin container."""
89-
return grpcbin_container["host"]
83+
return grpcbin_extension.container_host
9084

9185

9286
@pytest.fixture
93-
def grpcbin_insecure_port(grpcbin_container):
87+
def grpcbin_insecure_port(grpcbin_extension):
9488
"""Return the insecure (HTTP/2 without TLS) port for grpcbin."""
95-
return grpcbin_container["insecure_port"]
89+
return GRPCBIN_INSECURE_PORT
9690

9791

9892
@pytest.fixture
99-
def grpcbin_secure_port(grpcbin_container):
93+
def grpcbin_secure_port(grpcbin_extension):
10094
"""Return the secure (HTTP/2 with TLS) port for grpcbin."""
101-
return grpcbin_container["secure_port"]
95+
return GRPCBIN_SECURE_PORT
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""
2+
Integration tests for ProxiedDockerContainerExtension with grpcbin.
3+
4+
These tests verify that ProxiedDockerContainerExtension properly manages
5+
Docker containers in a realistic scenario, using grpcbin as a test service.
6+
"""
7+
8+
import socket
9+
10+
11+
class TestProxiedDockerContainerExtension:
12+
"""Tests for ProxiedDockerContainerExtension using the GrpcbinExtension."""
13+
14+
def test_extension_starts_container(self, grpcbin_extension):
15+
"""Test that the extension successfully starts the Docker container."""
16+
assert grpcbin_extension.container_name == "ls-ext-grpcbin-test"
17+
assert grpcbin_extension.image_name == "moul/grpcbin"
18+
assert len(grpcbin_extension.container_ports) == 2
19+
20+
def test_extension_container_host_is_accessible(self, grpcbin_extension):
21+
"""Test that the container_host is set and accessible."""
22+
assert grpcbin_extension.container_host is not None
23+
# container_host should be localhost, localhost.localstack.cloud, or a docker bridge IP
24+
assert grpcbin_extension.container_host in ("localhost", "127.0.0.1", "localhost.localstack.cloud") or \
25+
grpcbin_extension.container_host.startswith("172.")
26+
27+
def test_extension_ports_are_reachable(self, grpcbin_host, grpcbin_insecure_port):
28+
"""Test that the extension's ports are reachable via TCP."""
29+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
30+
sock.settimeout(2.0)
31+
try:
32+
sock.connect((grpcbin_host, grpcbin_insecure_port))
33+
sock.close()
34+
# Connection successful
35+
except (socket.timeout, socket.error) as e:
36+
raise AssertionError(f"Could not connect to grpcbin port: {e}")
37+
38+
def test_extension_implements_required_methods(self, grpcbin_extension):
39+
"""Test that the extension properly implements the required abstract methods."""
40+
from werkzeug.datastructures import Headers
41+
42+
# should_proxy_request should be callable
43+
result = grpcbin_extension.should_proxy_request(Headers())
44+
assert result is False, "gRPC services should not proxy through HTTP gateway"
45+
46+
def test_multiple_ports_configured(self, grpcbin_extension):
47+
"""Test that the extension properly handles multiple ports."""
48+
assert 9000 in grpcbin_extension.container_ports # insecure port
49+
assert 9001 in grpcbin_extension.container_ports # secure port

0 commit comments

Comments
 (0)