Skip to content

Commit 81e1ae9

Browse files
Brian KrafftCopilot
andcommitted
fix: address R2 review findings - shutdown wiring, /health auth bypass, HEALTHCHECK
- Wire GracefulShutdownHandler into uvicorn lifecycle via serve_with_shutdown() with finally block guaranteeing shutdown runs after server.serve() - Move /health endpoint OUTSIDE BearerTokenMiddleware for K8s probe access - Set uvicorn timeout_graceful_shutdown to half of shutdown budget - Change Dockerfile OPENSPACE_SHUTDOWN_TIMEOUT from 30 to 8 (fits Docker 10s) - Replace HEALTHCHECK with real HTTP /health endpoint check (not false-positive import) - Add 2 new tests: /health-outside-auth, shutdown-after-uvicorn, Docker timeout alignment - 41 deployment tests, 1925 total passed Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 11cbf70 commit 81e1ae9

3 files changed

Lines changed: 68 additions & 27 deletions

File tree

Dockerfile

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,18 +63,18 @@ ENV OPENSPACE_MCP_HOST=0.0.0.0 \
6363
OPENSPACE_MCP_PORT=8000 \
6464
OPENSPACE_MCP_TRANSPORT=streamable-http \
6565
OPENSPACE_LOG_LEVEL=INFO \
66-
OPENSPACE_SHUTDOWN_TIMEOUT=30 \
66+
OPENSPACE_SHUTDOWN_TIMEOUT=8 \
6767
OPENSPACE_METRICS_ENABLED=true \
6868
OPENSPACE_SKILL_STORE_PATH=/app/skills \
6969
PYTHONUNBUFFERED=1
7070

7171
EXPOSE 8000
7272

73-
# Health check: Python-based (works for both stdio and HTTP transports)
74-
# For HTTP transports, /health endpoint is available via Starlette
75-
# For stdio transport, override with HEALTHCHECK NONE in docker-compose
73+
# Health check: hits the real /health endpoint (unauthenticated)
74+
# Default transport is streamable-http, so HTTP check works.
75+
# For stdio transport, override with HEALTHCHECK NONE in docker-compose.
7676
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
77-
CMD python -c "from openspace.observability.health import health; r=health.check(); exit(0 if r.get('status')=='healthy' else 1)" || exit 1
77+
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
7878

7979
# Graceful shutdown: Docker sends SIGTERM, handler drains in-flight tasks
8080
STOPSIGNAL SIGTERM

openspace/mcp/server.py

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -250,9 +250,14 @@ def run_mcp_server(mcp=None) -> None:
250250
else:
251251
starlette_app = mcp.streamable_http_app()
252252

253-
# Add /health endpoint for container orchestrators
253+
# Auth middleware chain: request → BearerAuth → RateLimit → MCP app
254+
rate_limited_app = RateLimitMiddleware(starlette_app)
255+
protected_app = BearerTokenMiddleware(rate_limited_app, token)
256+
257+
# /health is OUTSIDE auth — K8s probes can't send bearer tokens
258+
from starlette.applications import Starlette
254259
from starlette.responses import JSONResponse
255-
from starlette.routing import Route
260+
from starlette.routing import Mount, Route
256261

257262
from openspace.observability.health import health
258263

@@ -261,40 +266,43 @@ async def health_endpoint(request):
261266
status_code = 200 if result.get("status") == "healthy" else 503
262267
return JSONResponse(result, status_code=status_code)
263268

264-
# Wrap the MCP app with health route
265-
from starlette.applications import Starlette
266-
from starlette.routing import Mount
267-
268-
health_app = Starlette(
269+
app = Starlette(
269270
routes=[
270271
Route("/health", health_endpoint, methods=["GET"]),
271-
Mount("/", app=starlette_app),
272+
Mount("/", app=protected_app),
272273
]
273274
)
274275

275-
# Middleware chain: request → BearerAuth → RateLimit → health+MCP app
276-
rate_limited_app = RateLimitMiddleware(health_app)
277-
protected_app = BearerTokenMiddleware(rate_limited_app, token)
278-
279276
logger.info(
280277
"Starting MCP server with bearer auth + rate limiting on %s:%d (%s transport)",
281278
args.host,
282279
args.port,
283280
args.transport,
284281
)
285282

286-
# Install graceful shutdown handler
283+
# Graceful shutdown: uvicorn handles SIGTERM → drain HTTP → our hooks run after
287284
from openspace.deploy.shutdown import GracefulShutdownHandler
288285

289286
shutdown_handler = GracefulShutdownHandler(timeout=deploy_cfg.shutdown_timeout)
290287

288+
# Give uvicorn half the budget for HTTP draining, our handler gets the rest
289+
uvicorn_timeout = max(deploy_cfg.shutdown_timeout // 2, 1)
290+
291291
config = uvicorn.Config(
292-
protected_app,
292+
app,
293293
host=args.host,
294294
port=args.port,
295295
log_level=deploy_cfg.log_level.lower(),
296+
timeout_graceful_shutdown=uvicorn_timeout,
296297
)
297298
server = uvicorn.Server(config)
299+
300+
async def serve_with_shutdown():
301+
try:
302+
await server.serve()
303+
finally:
304+
await shutdown_handler.shutdown()
305+
298306
import anyio
299307

300-
anyio.run(server.serve)
308+
anyio.run(serve_with_shutdown)

tests/test_deployment.py

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -326,12 +326,31 @@ def test_dockerfile_non_root_user(self):
326326
user_lines = [l for l in lines if l.strip().startswith("USER ")]
327327
assert user_lines, "Dockerfile must have USER instruction (not in comment)"
328328

329+
def test_dockerfile_shutdown_timeout_under_docker_default(self):
330+
"""Dockerfile OPENSPACE_SHUTDOWN_TIMEOUT must be < Docker's 10s default."""
331+
content = (ROOT / "Dockerfile").read_text()
332+
import re
333+
334+
match = re.search(r"OPENSPACE_SHUTDOWN_TIMEOUT=(\d+)", content)
335+
assert match, "Dockerfile must set OPENSPACE_SHUTDOWN_TIMEOUT"
336+
timeout = int(match.group(1))
337+
assert timeout < 10, (
338+
f"Shutdown timeout {timeout}s must be < Docker's 10s stop_grace_period"
339+
)
340+
329341
def test_dockerfile_healthcheck(self):
330342
content = (ROOT / "Dockerfile").read_text()
331343
lines = content.splitlines()
332344
hc_lines = [l for l in lines if l.strip().startswith("HEALTHCHECK")]
333345
assert hc_lines, "Dockerfile must have HEALTHCHECK instruction"
334346

347+
def test_dockerfile_healthcheck_hits_real_endpoint(self):
348+
"""HEALTHCHECK must hit /health HTTP endpoint, not just import."""
349+
content = (ROOT / "Dockerfile").read_text()
350+
assert "localhost:8000/health" in content, (
351+
"HEALTHCHECK must hit real /health endpoint for accurate checks"
352+
)
353+
335354
def test_dockerfile_no_secrets(self):
336355
"""Dockerfile must not contain hardcoded secrets."""
337356
content = (ROOT / "Dockerfile").read_text().lower()
@@ -378,23 +397,37 @@ def test_deploy_config_wired_into_server(self):
378397
source = inspect.getsource(run_mcp_server)
379398
assert "DeployConfig" in source, "DeployConfig must be used in run_mcp_server"
380399

381-
def test_health_endpoint_wired_into_server(self):
382-
"""HTTP transports have /health route."""
400+
def test_health_endpoint_outside_auth(self):
401+
"""/health must be accessible WITHOUT bearer auth for K8s probes."""
383402
import inspect
384403

385404
from openspace.mcp.server import run_mcp_server
386405

387406
source = inspect.getsource(run_mcp_server)
388-
assert "/health" in source, "/health route must exist for HTTP transports"
389-
390-
def test_shutdown_handler_wired_into_server(self):
391-
"""GracefulShutdownHandler is created in run_mcp_server."""
407+
# /health route must be mounted BEFORE auth middleware in ASGI chain
408+
health_pos = source.find("Route(\"/health\"")
409+
bearer_pos = source.find("BearerTokenMiddleware")
410+
assert health_pos > 0, "/health route must exist"
411+
assert bearer_pos > 0, "BearerTokenMiddleware must exist"
412+
# In the code, auth wraps the MCP app; health wraps auth+MCP at top level
413+
# So health_endpoint definition comes AFTER protected_app is built
414+
assert health_pos > bearer_pos, (
415+
"/health must be outside auth — route should wrap protected_app"
416+
)
417+
418+
def test_shutdown_runs_after_uvicorn(self):
419+
"""GracefulShutdownHandler.shutdown() must run after server.serve()."""
392420
import inspect
393421

394422
from openspace.mcp.server import run_mcp_server
395423

396424
source = inspect.getsource(run_mcp_server)
397-
assert "GracefulShutdownHandler" in source
425+
assert "serve_with_shutdown" in source, (
426+
"Shutdown must be wired via serve_with_shutdown wrapper"
427+
)
428+
assert "finally:" in source, (
429+
"Shutdown must run in finally block to guarantee execution"
430+
)
398431

399432

400433
# ═══════════════════════════════════════════════════════════════════════

0 commit comments

Comments
 (0)