Files
2026-07-13 13:17:40 +08:00

507 lines
17 KiB
Python

import asyncio
import json
import os
import ssl
import tempfile
import pytest
import requests
import websockets
import ray
from ray import serve
from ray._common.tls_utils import generate_self_signed_tls_certs
from ray.serve._private.constants import RAY_SERVE_ENABLE_HA_PROXY
from ray.serve.config import HTTPOptions
# HAProxy ingress is HTTP-only and does not terminate TLS, so HTTPS and wss
# requests can't pass through it. Skip these tests when HAProxy is enabled.
skip_https_under_haproxy = pytest.mark.skipif(
RAY_SERVE_ENABLE_HA_PROXY,
reason="HAProxy ingress serves HTTP only; TLS is handled by direct ingress "
"or an upstream proxy.",
)
@pytest.fixture(scope="session")
def ssl_cert_and_key():
"""Generate SSL certificates using Ray's built-in utilities for testing."""
# Generate certificate and key using Ray's utility
cert_contents, key_contents = generate_self_signed_tls_certs()
# Create temp directory that persists for the session
temp_dir = tempfile.mkdtemp(prefix="ray_serve_https_test_")
# Write server certificate and key
cert_path = os.path.join(temp_dir, "server.crt")
key_path = os.path.join(temp_dir, "server.key")
with open(cert_path, "w") as f:
f.write(cert_contents)
with open(key_path, "w") as f:
f.write(key_contents)
yield {
"key_path": key_path,
"cert_path": cert_path,
"temp_dir": temp_dir,
}
# Cleanup
import shutil
try:
shutil.rmtree(temp_dir)
except Exception:
pass # Ignore cleanup errors
@pytest.fixture
def https_serve_instance(ssl_cert_and_key):
"""Start Ray Serve with HTTPS enabled."""
# Ensure Ray is shutdown before starting
try:
ray.shutdown()
except Exception:
pass
# Disable runtime env upload (dashboard should work now that it's built)
ray.init(runtime_env={"working_dir": None})
serve.start(
http_options=HTTPOptions(
ssl_keyfile=ssl_cert_and_key["key_path"],
ssl_certfile=ssl_cert_and_key["cert_path"],
)
)
yield serve
serve.shutdown()
ray.shutdown()
@skip_https_under_haproxy
class TestHTTPSProxy:
def test_https_basic_deployment(self, https_serve_instance):
"""Test basic HTTPS deployment functionality."""
@serve.deployment
def hello():
return "Hello HTTPS!"
serve.run(hello.bind())
# Test HTTPS request with certificate verification disabled for self-signed cert
response = requests.get(
"https://localhost:8000/hello",
verify=False, # Skip cert verification for self-signed
)
assert response.status_code == 200
assert response.text == "Hello HTTPS!"
def test_https_vs_http_requests(self, https_serve_instance):
"""Test that HTTP requests fail when HTTPS is enabled."""
@serve.deployment
def echo():
return "echo"
serve.run(echo.bind())
# HTTPS request should succeed
https_response = requests.get("https://localhost:8000/echo", verify=False)
assert https_response.status_code == 200
# HTTP request should fail with connection error
with pytest.raises(requests.exceptions.ConnectionError):
requests.get("http://localhost:8000/echo", timeout=5)
def test_https_with_fastapi_deployment(self, https_serve_instance):
"""Test HTTPS with FastAPI-based deployment."""
from fastapi import FastAPI
app = FastAPI()
@app.get("/items/{item_id}")
async def read_item(item_id: int):
return {"item_id": item_id, "secure": True}
@serve.deployment
@serve.ingress(app)
class FastAPIDeployment:
pass
serve.run(FastAPIDeployment.bind())
response = requests.get("https://localhost:8000/items/42", verify=False)
assert response.status_code == 200
assert response.json() == {"item_id": 42, "secure": True}
def test_https_concurrent_requests(self, https_serve_instance):
"""Test HTTPS with concurrent requests."""
import concurrent.futures
@serve.deployment
def concurrent_handler():
import time
time.sleep(0.1) # Small delay to test concurrency
return "concurrent"
serve.run(concurrent_handler.bind())
def make_request():
return requests.get(
"https://localhost:8000/concurrent_handler", verify=False
)
# Send 10 concurrent requests
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
futures = [executor.submit(make_request) for _ in range(10)]
responses = [f.result() for f in futures]
# All requests should succeed
for response in responses:
assert response.status_code == 200
assert response.text == "concurrent"
def test_https_large_payload(self, https_serve_instance):
"""Test HTTPS with large payloads."""
@serve.deployment
class LargePayloadHandler:
def __call__(self, request):
# Return a large response (1MB)
large_data = "x" * (1024 * 1024) # 1MB string
return {"data": large_data, "size": len(large_data)}
serve.run(LargePayloadHandler.bind())
response = requests.get(
"https://localhost:8000/LargePayloadHandler", verify=False
)
assert response.status_code == 200
data = response.json()
assert data["size"] == 1024 * 1024
assert len(data["data"]) == 1024 * 1024
def test_https_websocket_with_fastapi(self, https_serve_instance):
"""Test WebSocket functionality with FastAPI over HTTPS."""
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
app = FastAPI()
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
try:
while True:
# Receive message from client
data = await websocket.receive_text()
message = json.loads(data)
# Echo back with modification
response = {
"echo": message.get("message", ""),
"secure": True,
"protocol": "wss",
}
await websocket.send_text(json.dumps(response))
except WebSocketDisconnect:
pass
@serve.deployment
@serve.ingress(app)
class WebSocketDeployment:
pass
serve.run(WebSocketDeployment.bind())
# Test WebSocket connection over HTTPS (wss://)
async def test_websocket():
# Create SSL context that doesn't verify certificates (for self-signed certs)
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
uri = "wss://localhost:8000/ws"
async with websockets.connect(uri, ssl=ssl_context) as websocket:
# Send test message
test_message = {"message": "Hello WebSocket over HTTPS!"}
await websocket.send(json.dumps(test_message))
# Receive response
response = await websocket.recv()
data = json.loads(response)
# Verify response
assert data["echo"] == "Hello WebSocket over HTTPS!"
assert data["secure"] is True
assert data["protocol"] == "wss"
# Send another message to test bidirectional communication
test_message2 = {"message": "Second message"}
await websocket.send(json.dumps(test_message2))
response2 = await websocket.recv()
data2 = json.loads(response2)
assert data2["echo"] == "Second message"
# Run the async test
asyncio.run(test_websocket())
def test_https_websocket_multiple_connections(self, https_serve_instance):
"""Test multiple WebSocket connections over HTTPS."""
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
app = FastAPI()
# Store active connections
connections = []
@app.websocket("/ws/broadcast")
async def websocket_broadcast(websocket: WebSocket):
await websocket.accept()
connections.append(websocket)
try:
while True:
data = await websocket.receive_text()
message = json.loads(data)
# Broadcast to all connections
broadcast_message = {
"type": "broadcast",
"message": message.get("message", ""),
"connections": len(connections),
"secure": True,
}
# Send to all connected clients
disconnected = []
for conn in connections:
try:
await conn.send_text(json.dumps(broadcast_message))
except Exception:
disconnected.append(conn)
# Remove disconnected clients
for conn in disconnected:
connections.remove(conn)
except WebSocketDisconnect:
if websocket in connections:
connections.remove(websocket)
@serve.deployment
@serve.ingress(app)
class WebSocketBroadcastDeployment:
pass
serve.run(WebSocketBroadcastDeployment.bind())
async def test_multiple_websockets():
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
uri = "wss://localhost:8000/ws/broadcast"
# Connect multiple clients
websocket1 = await websockets.connect(uri, ssl=ssl_context)
websocket2 = await websockets.connect(uri, ssl=ssl_context)
try:
# Send message from client 1
test_message = {"message": "Hello from client 1"}
await websocket1.send(json.dumps(test_message))
# Both clients should receive the broadcast
response1 = await websocket1.recv()
response2 = await websocket2.recv()
data1 = json.loads(response1)
data2 = json.loads(response2)
# Verify both received the same broadcast
assert data1["type"] == "broadcast"
assert data1["message"] == "Hello from client 1"
assert data1["connections"] == 2
assert data1["secure"] is True
assert data2["type"] == "broadcast"
assert data2["message"] == "Hello from client 1"
assert data2["connections"] == 2
assert data2["secure"] is True
finally:
await websocket1.close()
await websocket2.close()
# Run the async test
asyncio.run(test_multiple_websockets())
class TestSSLConfiguration:
def test_ssl_config_validation_success(self, ssl_cert_and_key):
"""Test successful SSL configuration validation."""
key_path = ssl_cert_and_key["key_path"]
cert_path = ssl_cert_and_key["cert_path"]
# Should not raise exception
options = HTTPOptions(ssl_keyfile=key_path, ssl_certfile=cert_path)
assert options.ssl_keyfile == key_path
assert options.ssl_certfile == cert_path
def test_ssl_config_validation_missing_key(self):
"""Test SSL configuration validation with missing key file."""
with tempfile.TemporaryDirectory() as temp_dir:
cert_path = os.path.join(temp_dir, "test.crt")
with open(cert_path, "w") as f:
f.write("dummy cert")
with pytest.raises(ValueError) as exc_info:
HTTPOptions(ssl_keyfile=None, ssl_certfile=cert_path)
assert "Both ssl_keyfile and ssl_certfile must be provided together" in str(
exc_info.value
)
def test_ssl_config_validation_missing_cert(self):
"""Test SSL configuration validation with missing cert file."""
with tempfile.TemporaryDirectory() as temp_dir:
key_path = os.path.join(temp_dir, "test.key")
with open(key_path, "w") as f:
f.write("dummy key")
with pytest.raises(ValueError) as exc_info:
HTTPOptions(ssl_keyfile=key_path, ssl_certfile=None)
assert "Both ssl_keyfile and ssl_certfile must be provided together" in str(
exc_info.value
)
def test_ssl_config_with_password(self, ssl_cert_and_key):
"""Test SSL configuration with key file password."""
key_path = ssl_cert_and_key["key_path"]
cert_path = ssl_cert_and_key["cert_path"]
options = HTTPOptions(
ssl_keyfile=key_path, ssl_certfile=cert_path, ssl_keyfile_password="secret"
)
assert options.ssl_keyfile_password == "secret"
def test_ssl_config_with_ca_certs(self, ssl_cert_and_key):
"""Test SSL configuration with CA certificates."""
key_path = ssl_cert_and_key["key_path"]
cert_path = ssl_cert_and_key["cert_path"]
# Use cert as CA for testing purposes
ca_path = cert_path
options = HTTPOptions(
ssl_keyfile=key_path, ssl_certfile=cert_path, ssl_ca_certs=ca_path
)
assert options.ssl_ca_certs == ca_path
class TestHTTPSErrorHandling:
def test_ssl_file_paths_validation(self):
"""Test that SSL file paths are properly configured in HTTPOptions."""
with tempfile.TemporaryDirectory() as temp_dir:
key_path = os.path.join(temp_dir, "test.key")
cert_path = os.path.join(temp_dir, "test.crt")
# Create dummy files (content doesn't matter for this test)
with open(key_path, "w") as f:
f.write("dummy key")
with open(cert_path, "w") as f:
f.write("dummy cert")
# Test that HTTPOptions accepts valid file paths
options = HTTPOptions(ssl_keyfile=key_path, ssl_certfile=cert_path)
assert options.ssl_keyfile == key_path
assert options.ssl_certfile == cert_path
def test_https_requires_both_cert_and_key_files(self):
"""Test that HTTPS configuration requires both certificate and key files."""
# This test validates our SSL validation logic works correctly
# Should work with both files
options = HTTPOptions(ssl_keyfile="key.pem", ssl_certfile="cert.pem")
assert options.ssl_keyfile == "key.pem"
assert options.ssl_certfile == "cert.pem"
# Should work with neither file
options = HTTPOptions()
assert options.ssl_keyfile is None
assert options.ssl_certfile is None
@skip_https_under_haproxy
class TestHTTPSIntegration:
def test_https_with_custom_port(self, ssl_cert_and_key):
"""Test HTTPS on custom port."""
# Ensure Ray is shutdown before starting
try:
ray.shutdown()
except Exception:
pass
# Disable dashboard to prevent SSL conflicts and disable runtime env upload
ray.init(include_dashboard=False, runtime_env={"working_dir": None})
try:
serve.start(
http_options=HTTPOptions(
host="127.0.0.1",
port=8443,
ssl_keyfile=ssl_cert_and_key["key_path"],
ssl_certfile=ssl_cert_and_key["cert_path"],
)
)
@serve.deployment
def custom_port_handler():
return "custom port"
serve.run(custom_port_handler.bind())
response = requests.get(
"https://127.0.0.1:8443/custom_port_handler", verify=False
)
assert response.status_code == 200
assert response.text == "custom port"
finally:
try:
serve.shutdown()
except Exception:
pass
ray.shutdown()
def test_https_deployment_update(self, https_serve_instance):
"""Test deployment updates work correctly with HTTPS."""
@serve.deployment
def updatable():
return "version 1"
serve.run(updatable.bind())
# Test initial version
response = requests.get("https://localhost:8000/updatable", verify=False)
assert response.text == "version 1"
# Update deployment
@serve.deployment
def updatable():
return "version 2"
serve.run(updatable.bind())
# Test updated version
response = requests.get("https://localhost:8000/updatable", verify=False)
assert response.text == "version 2"
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-s", __file__]))