test(dirty): add Docker setup for dirty example integration tests

- Add Dockerfile and docker-compose.yml for running examples in containers
- Add test_integration.py for HTTP-level integration testing
- Update test_worker_integration.py to use MockWriter for handle_request
- Use integer request IDs for binary protocol compatibility
- Add GUNICORN_BIND env var support in gunicorn_conf.py for Docker
This commit is contained in:
Benoit Chesneau 2026-02-11 23:30:48 +01:00
parent 00da70292f
commit c0cc8c0de0
5 changed files with 210 additions and 10 deletions

View File

@ -0,0 +1,25 @@
#
# This file is part of gunicorn released under the MIT license.
# See the NOTICE for more information.
FROM python:3.12-slim
WORKDIR /app
# Copy gunicorn source
COPY . /app/gunicorn-src
# Install gunicorn and dependencies
# setproctitle is needed for process title changes
RUN pip install --no-cache-dir /app/gunicorn-src setproctitle
# Copy example files
COPY examples/dirty_example/ /app/examples/dirty_example/
WORKDIR /app
# Expose the port
EXPOSE 8000
# Default command - run the example tests
CMD ["python", "-m", "pytest", "-v", "examples/dirty_example/"]

View File

@ -0,0 +1,54 @@
#
# This file is part of gunicorn released under the MIT license.
# See the NOTICE for more information.
services:
# Run the example tests (protocol, dirty app, worker integration)
tests:
build:
context: ../..
dockerfile: examples/dirty_example/Dockerfile
command: >
bash -c "
echo '=== Running Protocol Tests ===' &&
python examples/dirty_example/test_protocol.py &&
echo '' &&
echo '=== Running Dirty App Tests ===' &&
python examples/dirty_example/test_dirty_app.py &&
echo '' &&
echo '=== Running Worker Integration Tests ===' &&
python examples/dirty_example/test_worker_integration.py &&
echo '' &&
echo '=== All tests passed! ==='
"
# Run the full gunicorn server with dirty workers
server:
build:
context: ../..
dockerfile: examples/dirty_example/Dockerfile
ports:
- "8001:8000"
environment:
- GUNICORN_BIND=0.0.0.0:8000
command: >
gunicorn examples.dirty_example.wsgi_app:app
-c examples/dirty_example/gunicorn_conf.py
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/')"]
interval: 5s
timeout: 5s
retries: 5
start_period: 10s
# Run integration test against the server
integration-test:
build:
context: ../..
dockerfile: examples/dirty_example/Dockerfile
depends_on:
server:
condition: service_healthy
environment:
- TEST_BASE_URL=http://server:8000
command: python examples/dirty_example/test_integration.py

View File

@ -11,7 +11,9 @@ Run with:
"""
# Basic settings
bind = "127.0.0.1:8000"
# Use 0.0.0.0 for Docker, override with GUNICORN_BIND env var if needed
import os
bind = os.environ.get("GUNICORN_BIND", "127.0.0.1:8000")
workers = 2
worker_class = "sync"
timeout = 30

View File

@ -0,0 +1,81 @@
#
# This file is part of gunicorn released under the MIT license.
# See the NOTICE for more information.
#!/usr/bin/env python
"""
Integration test for the dirty example server.
This tests that the full gunicorn server with dirty workers responds
correctly to HTTP requests.
Run with:
python examples/dirty_example/test_integration.py [base_url]
Default base_url is http://localhost:8000
"""
import sys
import os
import json
import urllib.request
import urllib.error
def test_endpoint(base, path, expected_key=None):
"""Test an endpoint and check for expected key in response."""
url = base + path
print(f"Testing: {url}")
try:
with urllib.request.urlopen(url, timeout=10) as resp:
data = json.loads(resp.read())
print(f" Response: {str(data)[:200]}")
if expected_key and expected_key not in data:
print(f" ERROR: Expected key '{expected_key}' not found!")
return False
return True
except urllib.error.HTTPError as e:
print(f" HTTP ERROR {e.code}: {e.reason}")
return False
except Exception as e:
print(f" ERROR: {e}")
return False
def main():
# Get base URL from env or command line
base = os.environ.get("TEST_BASE_URL", "http://localhost:8000")
if len(sys.argv) > 1:
base = sys.argv[1]
print(f"Testing dirty example server at: {base}")
print("=" * 60)
# Define tests: (path, expected_key_in_response)
tests = [
("/", "endpoints"),
("/models", "models"),
("/load?name=test-model", "status"),
("/inference?model=default&data=hello", "prediction"),
("/fibonacci?n=20", "result"),
("/prime?n=17", "is_prime"),
("/stats", "ml_app"),
("/unload?name=test-model", "status"),
]
failed = 0
for path, key in tests:
if not test_endpoint(base, path, key):
failed += 1
print()
print("=" * 60)
if failed:
print(f"FAILED: {failed} tests failed")
sys.exit(1)
else:
print("SUCCESS: All integration tests passed!")
if __name__ == "__main__":
main()

View File

@ -22,7 +22,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspa
from gunicorn.config import Config
from gunicorn.dirty.worker import DirtyWorker
from gunicorn.dirty.protocol import DirtyProtocol, make_request
from gunicorn.dirty.protocol import DirtyProtocol, BinaryProtocol, make_request, HEADER_SIZE
class MockLog:
@ -35,6 +35,36 @@ class MockLog:
def reopen_files(self): pass
class MockWriter:
"""Mock StreamWriter that captures written responses."""
def __init__(self):
self.messages = []
self._buffer = b""
def write(self, data):
self._buffer += data
async def drain(self):
# Decode messages from buffer using binary protocol
while len(self._buffer) >= HEADER_SIZE:
_, _, length = BinaryProtocol.decode_header(self._buffer[:HEADER_SIZE])
total_size = HEADER_SIZE + length
if len(self._buffer) >= total_size:
msg_data = self._buffer[:total_size]
self._buffer = self._buffer[total_size:]
msg_type_str, request_id, payload_dict = BinaryProtocol.decode_message(msg_data)
result = {"type": msg_type_str, "id": request_id}
result.update(payload_dict)
self.messages.append(result)
else:
break
def get_last_response(self):
"""Get the last response message."""
return self.messages[-1] if self.messages else None
async def test_worker_request_handling():
"""Test that a worker can load apps and handle requests."""
print("=" * 60)
@ -75,52 +105,60 @@ async def test_worker_request_handling():
# Test handle_request with a proper request message
print("\n3. Testing handle_request() - load_model...")
request = make_request(
request_id="test-001",
request_id=1001,
app_path="examples.dirty_example.dirty_app:MLApp",
action="load_model",
args=("gpt-4",),
kwargs={}
)
response = await worker.handle_request(request)
writer = MockWriter()
await worker.handle_request(request, writer)
response = writer.get_last_response()
print(f" Response type: {response['type']}")
print(f" Result: {response.get('result', response.get('error'))}")
# Test inference
print("\n4. Testing handle_request() - inference...")
request = make_request(
request_id="test-002",
request_id=1002,
app_path="examples.dirty_example.dirty_app:MLApp",
action="inference",
args=("default", "Hello AI!"),
kwargs={}
)
response = await worker.handle_request(request)
writer = MockWriter()
await worker.handle_request(request, writer)
response = writer.get_last_response()
print(f" Response type: {response['type']}")
print(f" Result: {response.get('result', response.get('error'))}")
# Test error handling
print("\n5. Testing error handling - unknown action...")
request = make_request(
request_id="test-003",
request_id=1003,
app_path="examples.dirty_example.dirty_app:MLApp",
action="nonexistent_action",
args=(),
kwargs={}
)
response = await worker.handle_request(request)
writer = MockWriter()
await worker.handle_request(request, writer)
response = writer.get_last_response()
print(f" Response type: {response['type']}")
print(f" Error: {response.get('error', {}).get('message')}")
# Test app not found
print("\n6. Testing error handling - app not found...")
request = make_request(
request_id="test-004",
request_id=1004,
app_path="nonexistent:App",
action="test",
args=(),
kwargs={}
)
response = await worker.handle_request(request)
writer = MockWriter()
await worker.handle_request(request, writer)
response = writer.get_last_response()
print(f" Response type: {response['type']}")
print(f" Error type: {response.get('error', {}).get('error_type')}")