From c0cc8c0de056d3306e44dee1f2d53bbc054276af Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Wed, 11 Feb 2026 23:30:48 +0100 Subject: [PATCH] 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 --- examples/dirty_example/Dockerfile | 25 ++++++ examples/dirty_example/docker-compose.yml | 54 +++++++++++++ examples/dirty_example/gunicorn_conf.py | 4 +- examples/dirty_example/test_integration.py | 81 +++++++++++++++++++ .../dirty_example/test_worker_integration.py | 56 ++++++++++--- 5 files changed, 210 insertions(+), 10 deletions(-) create mode 100644 examples/dirty_example/Dockerfile create mode 100644 examples/dirty_example/docker-compose.yml create mode 100644 examples/dirty_example/test_integration.py diff --git a/examples/dirty_example/Dockerfile b/examples/dirty_example/Dockerfile new file mode 100644 index 00000000..302578dc --- /dev/null +++ b/examples/dirty_example/Dockerfile @@ -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/"] diff --git a/examples/dirty_example/docker-compose.yml b/examples/dirty_example/docker-compose.yml new file mode 100644 index 00000000..c55669a3 --- /dev/null +++ b/examples/dirty_example/docker-compose.yml @@ -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 diff --git a/examples/dirty_example/gunicorn_conf.py b/examples/dirty_example/gunicorn_conf.py index a7877c3e..ba3d28e4 100644 --- a/examples/dirty_example/gunicorn_conf.py +++ b/examples/dirty_example/gunicorn_conf.py @@ -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 diff --git a/examples/dirty_example/test_integration.py b/examples/dirty_example/test_integration.py new file mode 100644 index 00000000..20008522 --- /dev/null +++ b/examples/dirty_example/test_integration.py @@ -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() diff --git a/examples/dirty_example/test_worker_integration.py b/examples/dirty_example/test_worker_integration.py index 1711ee3d..acca9961 100644 --- a/examples/dirty_example/test_worker_integration.py +++ b/examples/dirty_example/test_worker_integration.py @@ -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')}")