diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..a879153b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,72 @@ +# ============================================ +# Docker build context exclusions +# ============================================ + +# Version control +.git +.gitignore + +# Tests and development +tests/ +.pytest_cache/ +__pycache__/ +*.pyc +.coverage +htmlcov/ +coverage.xml + +# Frontend (separate build) +frontend/ + +# IDE and editor +.vscode/ +.idea/ +*.swp +*.swo + +# Documentation and meta +*.md +!README.md +LICENSE +RUNBOOK.yaml +docs/ +showcase/ + +# Environment and secrets +.env +.env.* +!.env.example +*.key +*.pem +*.p12 +*.pfx +*.crt + +# OS files +.DS_Store +Thumbs.db + +# Build artifacts +dist/ +build/ +*.egg-info/ +scion_skills.egg-info/ + +# Caches +.mypy_cache/ +.ruff_cache/ + +# Benchmarks and external +gdpval_bench/ +scion/ + +# Local state +.openspace/ + +# Logs +logs/ +*.log + +# PR artifacts +pr-*-diff*.txt +docker-compose*.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..bf42505d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,83 @@ +# ============================================ +# OpenSpace Multi-Stage Dockerfile +# ============================================ +# Build: docker build -t openspace . +# Run: docker run -p 8000:8000 --env-file .env openspace +# +# Environment variables (see .env.example): +# OPENSPACE_MCP_HOST, OPENSPACE_MCP_PORT, OPENSPACE_MCP_TRANSPORT, +# OPENSPACE_LOG_LEVEL, OPENSPACE_SHUTDOWN_TIMEOUT, +# OPENSPACE_METRICS_ENABLED, OPENROUTER_API_KEY, etc. +# ============================================ + +# ── Stage 1: Build ────────────────────────────────────────────────── +FROM python:3.13-slim AS builder + +WORKDIR /build + +# Install build dependencies +RUN apt-get update && \ + apt-get install -y --no-install-recommends gcc && \ + rm -rf /var/lib/apt/lists/* + +# Copy dependency files first (layer caching — code changes don't bust this) +COPY pyproject.toml requirements.txt ./ + +# Install Python dependencies (cached unless requirements change) +RUN pip install --no-cache-dir --prefix=/install -r requirements.txt + +# Copy application code (separate layer — changes more often) +COPY openspace/ openspace/ + +# Install the package itself +RUN pip install --no-cache-dir --prefix=/install --no-deps . + +# ── Stage 2: Runtime ──────────────────────────────────────────────── +FROM python:3.13-slim AS runtime + +LABEL org.opencontainers.image.title="OpenSpace" \ + org.opencontainers.image.description="AI Agent Framework with MCP Server" \ + org.opencontainers.image.source="https://github.com/Deepfreezechill/OpenSpace" + +# Create non-root user +RUN groupadd --gid 1001 openspace && \ + useradd --uid 1001 --gid openspace --shell /bin/bash --create-home openspace + +# Copy installed packages from builder +COPY --from=builder /install /usr/local + +# Copy application code +WORKDIR /app +COPY openspace/ openspace/ +COPY pyproject.toml ./ + +# Create directories for runtime data +RUN mkdir -p /app/skills /app/logs && \ + chown -R openspace:openspace /app + +# Switch to non-root user +USER openspace + +# Default environment +ENV OPENSPACE_MCP_HOST=0.0.0.0 \ + OPENSPACE_MCP_PORT=8000 \ + OPENSPACE_MCP_TRANSPORT=streamable-http \ + OPENSPACE_LOG_LEVEL=INFO \ + OPENSPACE_SHUTDOWN_TIMEOUT=8 \ + OPENSPACE_METRICS_ENABLED=true \ + OPENSPACE_SKILL_STORE_PATH=/app/skills \ + PYTHONUNBUFFERED=1 + +EXPOSE 8000 + +# Health check: hits the real /health endpoint (unauthenticated) +# Default transport is streamable-http, so HTTP check works. +# For stdio transport, override with HEALTHCHECK NONE in docker-compose. +HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1 + +# Graceful shutdown: Docker sends SIGTERM, handler drains in-flight tasks +STOPSIGNAL SIGTERM + +ENTRYPOINT ["python", "-m", "openspace.mcp_server"] +CMD ["--transport", "streamable-http", "--host", "0.0.0.0", "--port", "8000"] diff --git a/RUNBOOK.yaml b/RUNBOOK.yaml index d3fcd54b..47c4c776 100644 --- a/RUNBOOK.yaml +++ b/RUNBOOK.yaml @@ -1,8 +1,8 @@ schema_version: '1.0' lock: - active_claim: null - claimed_by: null - claimed_at: null + active_claim: '6.3' + claimed_by: 2026-04-07-e9b4572b + claimed_at: '2026-04-07T01:20:00Z' review: max_rounds: 3 required_gates: @@ -562,8 +562,8 @@ epics: - id: '6.3' phase: P6 title: Deployment architecture - status: pending - branch: null + status: claimed + branch: epic/6.3-deployment pr: null review_round: 0 description: 'Containerization (Dockerfile), config management (env-based), graceful diff --git a/openspace/deploy/__init__.py b/openspace/deploy/__init__.py new file mode 100644 index 00000000..ba10d941 --- /dev/null +++ b/openspace/deploy/__init__.py @@ -0,0 +1,5 @@ +"""Deployment utilities for OpenSpace. + +Provides centralized configuration, graceful shutdown, and container +readiness for production deployments. +""" diff --git a/openspace/deploy/config.py b/openspace/deploy/config.py new file mode 100644 index 00000000..db3273d4 --- /dev/null +++ b/openspace/deploy/config.py @@ -0,0 +1,127 @@ +"""Centralized environment-based configuration for OpenSpace deployments. + +All deployment-relevant settings are read from environment variables with +sensible defaults. Configuration is frozen (immutable) after creation. + +Environment variables: + OPENSPACE_MCP_HOST MCP server bind address (default: 0.0.0.0) + OPENSPACE_MCP_PORT MCP server port (default: 8000) + OPENSPACE_MCP_TRANSPORT Transport: stdio|sse|streamable-http (default: stdio) + OPENSPACE_LOG_LEVEL Log level: DEBUG|INFO|WARNING|ERROR (default: INFO) + OPENSPACE_SHUTDOWN_TIMEOUT Graceful shutdown timeout in seconds (default: 30) + OPENSPACE_METRICS_ENABLED Enable Prometheus metrics (default: true) + OPENSPACE_SKILL_STORE_PATH Path to skill store directory (default: skills/) + OPENSPACE_DEBUG Enable debug mode (default: false) + +Usage:: + + from openspace.deploy.config import DeployConfig + + cfg = DeployConfig.from_env() + print(cfg.mcp_port) # 8000 +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Any, Dict + +_VALID_TRANSPORTS = ("stdio", "sse", "streamable-http") +_VALID_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL") + + +def _int_env(name: str, default: int) -> int: + """Parse an integer environment variable with graceful fallback.""" + raw = os.environ.get(name, "").strip() + if not raw: + return default + try: + return int(raw) + except ValueError: + raise ValueError( + f"Environment variable {name}={raw!r} is not a valid integer" + ) from None + + +@dataclass(frozen=True) +class DeployConfig: + """Immutable deployment configuration. + + All values have sane defaults and can be overridden via environment + variables using :meth:`from_env`. + """ + + mcp_host: str = "0.0.0.0" + mcp_port: int = 8000 + mcp_transport: str = "stdio" + log_level: str = "INFO" + shutdown_timeout: int = 30 + metrics_enabled: bool = True + skill_store_path: str = "skills/" + debug: bool = False + + def __post_init__(self) -> None: + if not (1 <= self.mcp_port <= 65535): + raise ValueError( + f"port must be 1-65535, got {self.mcp_port}" + ) + if self.mcp_transport not in _VALID_TRANSPORTS: + raise ValueError( + f"transport must be one of {_VALID_TRANSPORTS}, " + f"got {self.mcp_transport!r}" + ) + # Normalize log_level to uppercase + normalized = self.log_level.upper() + if normalized not in _VALID_LOG_LEVELS: + raise ValueError( + f"log_level must be one of {_VALID_LOG_LEVELS}, " + f"got {self.log_level!r}" + ) + if normalized != self.log_level: + object.__setattr__(self, "log_level", normalized) + if self.shutdown_timeout < 1: + raise ValueError( + f"shutdown_timeout must be >= 1, got {self.shutdown_timeout}" + ) + + @classmethod + def from_env(cls) -> DeployConfig: + """Create config from environment variables. + + Handles empty strings, whitespace, and non-integer values + gracefully with descriptive error messages. + """ + return cls( + mcp_host=os.environ.get("OPENSPACE_MCP_HOST", "0.0.0.0").strip() or "0.0.0.0", + mcp_port=_int_env("OPENSPACE_MCP_PORT", 8000), + mcp_transport=os.environ.get("OPENSPACE_MCP_TRANSPORT", "stdio").strip() or "stdio", + log_level=(os.environ.get("OPENSPACE_LOG_LEVEL", "INFO").strip() or "INFO").upper(), + shutdown_timeout=_int_env("OPENSPACE_SHUTDOWN_TIMEOUT", 30), + metrics_enabled=os.environ.get( + "OPENSPACE_METRICS_ENABLED", "true" + ).strip().lower() + in ("true", "1", "yes"), + skill_store_path=os.environ.get( + "OPENSPACE_SKILL_STORE_PATH", "skills/" + ).strip() or "skills/", + debug=os.environ.get("OPENSPACE_DEBUG", "false").strip().lower() + in ("true", "1", "yes"), + ) + + def to_safe_dict(self) -> Dict[str, Any]: + """Serialize config without secrets. + + Returns only deployment-relevant fields. API keys, tokens, + and other secrets are never included. + """ + return { + "mcp_host": self.mcp_host, + "mcp_port": self.mcp_port, + "mcp_transport": self.mcp_transport, + "log_level": self.log_level, + "shutdown_timeout": self.shutdown_timeout, + "metrics_enabled": self.metrics_enabled, + "skill_store_path": self.skill_store_path, + "debug": self.debug, + } diff --git a/openspace/deploy/shutdown.py b/openspace/deploy/shutdown.py new file mode 100644 index 00000000..73385f6f --- /dev/null +++ b/openspace/deploy/shutdown.py @@ -0,0 +1,147 @@ +"""Graceful shutdown handler for OpenSpace. + +Provides signal-aware shutdown that: +1. Stops accepting new requests +2. Drains in-flight tasks (with timeout) +3. Runs registered cleanup hooks +4. Exits cleanly + +Uses a single monotonic deadline for the entire shutdown sequence to +ensure Docker's stop_grace_period (default 10s) isn't exceeded. + +Usage:: + + from openspace.deploy.shutdown import GracefulShutdownHandler + + handler = GracefulShutdownHandler(timeout=30) + handler.register_hook(flush_metrics) + handler.register_hook(close_connections) + + # In your server setup: + handler.install_signal_handlers(loop) + + # On SIGTERM/SIGINT, handler.shutdown() runs automatically. +""" + +from __future__ import annotations + +import asyncio +import logging +import signal +import sys +import time +from typing import Any, Awaitable, Callable, List, Optional, Set + +logger = logging.getLogger(__name__) + +ShutdownHook = Callable[[], Awaitable[Any]] + + +class GracefulShutdownHandler: + """Manages graceful shutdown with a global timeout budget. + + The timeout is shared across drain + hooks phases to ensure + total shutdown time stays within Docker/K8s stop_grace_period. + """ + + def __init__(self, timeout: int = 30) -> None: + if timeout < 1: + raise ValueError(f"timeout must be >= 1 second, got {timeout}") + self._timeout = timeout + self._hooks: List[ShutdownHook] = [] + self._in_flight: Set[asyncio.Task[Any]] = set() + self._shutting_down = False + self._shutdown_complete = False + + def register_hook(self, hook: ShutdownHook) -> None: + """Register an async cleanup hook to run during shutdown.""" + self._hooks.append(hook) + + def track_task(self, task: asyncio.Task[Any]) -> None: + """Track an in-flight task that must complete before shutdown. + + Rejects new tasks once shutdown has started to prevent leaks. + """ + if self._shutting_down: + task.cancel() + return + self._in_flight.add(task) + task.add_done_callback(self._in_flight.discard) + + def install_signal_handlers(self, loop: Optional[asyncio.AbstractEventLoop] = None) -> None: + """Install SIGTERM/SIGINT handlers on the event loop.""" + if loop is None: + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = asyncio.get_event_loop() + + if sys.platform == "win32": + signal.signal(signal.SIGINT, self._sync_signal_handler) + else: + for sig in (signal.SIGTERM, signal.SIGINT): + loop.add_signal_handler( + sig, lambda s=sig: asyncio.ensure_future(self.shutdown()) + ) + + def _sync_signal_handler(self, signum: int, frame: Any) -> None: + """Fallback signal handler for Windows.""" + if self._shutting_down: + return + logger.info("Signal %d received, initiating shutdown...", signum) + try: + loop = asyncio.get_running_loop() + asyncio.ensure_future(self.shutdown()) + except RuntimeError: + pass # No running loop — process is exiting anyway + + async def shutdown(self) -> None: + """Execute graceful shutdown with global timeout budget. + + Allocates ~70% of timeout to draining tasks, ~30% to hooks. + Idempotent: safe to call multiple times. + """ + if self._shutdown_complete or self._shutting_down: + return + self._shutting_down = True + + deadline = time.monotonic() + self._timeout + drain_budget = self._timeout * 0.7 + logger.info( + "Graceful shutdown started (timeout=%ds, in_flight=%d, hooks=%d)", + self._timeout, + len(self._in_flight), + len(self._hooks), + ) + + # Phase 1: Drain in-flight tasks (70% of budget) + if self._in_flight: + snapshot = set(self._in_flight) + logger.info("Draining %d in-flight tasks...", len(snapshot)) + try: + await asyncio.wait_for( + asyncio.gather(*snapshot, return_exceptions=True), + timeout=drain_budget, + ) + except asyncio.TimeoutError: + logger.warning("Drain timeout, cancelling remaining tasks...") + for task in snapshot: + if not task.done(): + task.cancel() + await asyncio.gather(*snapshot, return_exceptions=True) + + # Phase 2: Run cleanup hooks (remaining budget) + for hook in self._hooks: + remaining = deadline - time.monotonic() + if remaining <= 0: + logger.warning("Shutdown deadline exceeded, skipping remaining hooks") + break + try: + await asyncio.wait_for(hook(), timeout=max(remaining, 0.1)) + except asyncio.TimeoutError: + logger.warning("Shutdown hook %s timed out", hook.__name__) + except Exception: + logger.exception("Shutdown hook %s failed", hook.__name__) + + self._shutdown_complete = True + logger.info("Graceful shutdown complete") diff --git a/openspace/mcp/server.py b/openspace/mcp/server.py index 4375dea2..bcbb77d2 100644 --- a/openspace/mcp/server.py +++ b/openspace/mcp/server.py @@ -188,11 +188,8 @@ def _probe_mcp_tools() -> HealthProbe: def run_mcp_server(mcp=None) -> None: """Console-script entry point for ``openspace-mcp``. - For HTTP transports (SSE, streamable-http), bearer token auth is - REQUIRED. Set OPENSPACE_MCP_BEARER_TOKEN in the environment. - The server refuses to start without it (fail-closed). - - For stdio transport, auth is not applicable (local process IPC). + Uses DeployConfig for defaults, with CLI args as overrides. + For HTTP transports, bearer token auth is REQUIRED. Args: mcp: Optional pre-created FastMCP instance. If None, creates one @@ -209,18 +206,22 @@ def run_mcp_server(mcp=None) -> None: validate_token_strength, ) from openspace.auth.rate_limit import RateLimitMiddleware + from openspace.deploy.config import DeployConfig if mcp is None: mcp = create_mcp_app() + # Load config from environment, then allow CLI overrides + deploy_cfg = DeployConfig.from_env() + parser = argparse.ArgumentParser(description="OpenSpace MCP Server") parser.add_argument( "--transport", choices=["stdio", "sse", "streamable-http"], - default="stdio", + default=deploy_cfg.mcp_transport, ) - parser.add_argument("--port", type=int, default=8080) - parser.add_argument("--host", type=str, default="127.0.0.1") + parser.add_argument("--port", type=int, default=deploy_cfg.mcp_port) + parser.add_argument("--host", type=str, default=deploy_cfg.mcp_host) args = parser.parse_args() if args.transport == "stdio": @@ -249,10 +250,29 @@ def run_mcp_server(mcp=None) -> None: else: starlette_app = mcp.streamable_http_app() - # Middleware chain: request → BearerAuth → RateLimit → MCP app + # Auth middleware chain: request → BearerAuth → RateLimit → MCP app rate_limited_app = RateLimitMiddleware(starlette_app) protected_app = BearerTokenMiddleware(rate_limited_app, token) + # /health is OUTSIDE auth — K8s probes can't send bearer tokens + from starlette.applications import Starlette + from starlette.responses import JSONResponse + from starlette.routing import Mount, Route + + from openspace.observability.health import health + + async def health_endpoint(request): + result = health.check() + status_code = 200 if result.get("status") == "healthy" else 503 + return JSONResponse(result, status_code=status_code) + + app = Starlette( + routes=[ + Route("/health", health_endpoint, methods=["GET"]), + Mount("/", app=protected_app), + ] + ) + logger.info( "Starting MCP server with bearer auth + rate limiting on %s:%d (%s transport)", args.host, @@ -260,13 +280,29 @@ def run_mcp_server(mcp=None) -> None: args.transport, ) + # Graceful shutdown: uvicorn handles SIGTERM → drain HTTP → our hooks run after + from openspace.deploy.shutdown import GracefulShutdownHandler + + shutdown_handler = GracefulShutdownHandler(timeout=deploy_cfg.shutdown_timeout) + + # Give uvicorn half the budget for HTTP draining, our handler gets the rest + uvicorn_timeout = max(deploy_cfg.shutdown_timeout // 2, 1) + config = uvicorn.Config( - protected_app, + app, host=args.host, port=args.port, - log_level="info", + log_level=deploy_cfg.log_level.lower(), + timeout_graceful_shutdown=uvicorn_timeout, ) server = uvicorn.Server(config) + + async def serve_with_shutdown(): + try: + await server.serve() + finally: + await shutdown_handler.shutdown() + import anyio - anyio.run(server.serve) + anyio.run(serve_with_shutdown) diff --git a/tests/test_deployment.py b/tests/test_deployment.py new file mode 100644 index 00000000..17444d3d --- /dev/null +++ b/tests/test_deployment.py @@ -0,0 +1,453 @@ +"""Tests for Epic 6.3: Deployment architecture. + +Tests config management, graceful shutdown, Dockerfile validity, +and deployment readiness. +""" + +from __future__ import annotations + +import asyncio +import os +import signal +import sys +import textwrap +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +ROOT = Path(__file__).resolve().parent.parent + + +# ═══════════════════════════════════════════════════════════════════════ +# Config management +# ═══════════════════════════════════════════════════════════════════════ + + +class TestDeployConfig: + """Centralized env-based configuration.""" + + def test_config_importable(self): + from openspace.deploy.config import DeployConfig + + assert DeployConfig is not None + + def test_defaults_without_env(self): + """Config provides sane defaults when no env vars set.""" + with patch.dict(os.environ, {}, clear=True): + from openspace.deploy.config import DeployConfig + + cfg = DeployConfig() + assert cfg.mcp_host == "0.0.0.0" + assert cfg.mcp_port == 8000 + assert cfg.mcp_transport == "stdio" + assert cfg.log_level == "INFO" + assert cfg.shutdown_timeout == 30 + assert cfg.metrics_enabled is True + + def test_env_override(self): + """Environment variables override defaults.""" + env = { + "OPENSPACE_MCP_HOST": "127.0.0.1", + "OPENSPACE_MCP_PORT": "9090", + "OPENSPACE_MCP_TRANSPORT": "sse", + "OPENSPACE_LOG_LEVEL": "DEBUG", + "OPENSPACE_SHUTDOWN_TIMEOUT": "60", + "OPENSPACE_METRICS_ENABLED": "false", + } + with patch.dict(os.environ, env, clear=True): + from openspace.deploy.config import DeployConfig + + cfg = DeployConfig.from_env() + assert cfg.mcp_host == "127.0.0.1" + assert cfg.mcp_port == 9090 + assert cfg.mcp_transport == "sse" + assert cfg.log_level == "DEBUG" + assert cfg.shutdown_timeout == 60 + assert cfg.metrics_enabled is False + + def test_invalid_port_rejected(self): + """Port outside valid range raises ValueError.""" + from openspace.deploy.config import DeployConfig + + with pytest.raises(ValueError, match="port"): + DeployConfig(mcp_port=99999) + + def test_invalid_transport_rejected(self): + """Unknown transport raises ValueError.""" + from openspace.deploy.config import DeployConfig + + with pytest.raises(ValueError, match="transport"): + DeployConfig(mcp_transport="websocket") + + def test_invalid_log_level_rejected(self): + """Invalid log level raises ValueError.""" + from openspace.deploy.config import DeployConfig + + with pytest.raises(ValueError, match="log_level"): + DeployConfig(log_level="VERBOSE") + + def test_negative_shutdown_timeout_rejected(self): + from openspace.deploy.config import DeployConfig + + with pytest.raises(ValueError, match="shutdown_timeout"): + DeployConfig(shutdown_timeout=-1) + + def test_zero_shutdown_timeout_rejected(self): + """timeout=0 would cause instant task cancellation.""" + from openspace.deploy.config import DeployConfig + + with pytest.raises(ValueError, match="shutdown_timeout"): + DeployConfig(shutdown_timeout=0) + + def test_port_boundary_values(self): + from openspace.deploy.config import DeployConfig + + # Valid boundaries + assert DeployConfig(mcp_port=1).mcp_port == 1 + assert DeployConfig(mcp_port=65535).mcp_port == 65535 + # Invalid boundaries + with pytest.raises(ValueError, match="port"): + DeployConfig(mcp_port=0) + with pytest.raises(ValueError, match="port"): + DeployConfig(mcp_port=65536) + + def test_log_level_normalized_to_upper(self): + from openspace.deploy.config import DeployConfig + + cfg = DeployConfig(log_level="debug") + assert cfg.log_level == "DEBUG" + + def test_non_integer_port_env_raises(self): + from openspace.deploy.config import DeployConfig + + with patch.dict(os.environ, {"OPENSPACE_MCP_PORT": "abc"}, clear=True): + with pytest.raises(ValueError, match="OPENSPACE_MCP_PORT"): + DeployConfig.from_env() + + def test_empty_port_env_uses_default(self): + from openspace.deploy.config import DeployConfig + + with patch.dict(os.environ, {"OPENSPACE_MCP_PORT": ""}, clear=True): + cfg = DeployConfig.from_env() + assert cfg.mcp_port == 8000 + + def test_boolean_env_parsing_variants(self): + """All boolean truthy values should work.""" + from openspace.deploy.config import DeployConfig + + for val in ("true", "True", "TRUE", "1", "yes"): + with patch.dict(os.environ, {"OPENSPACE_METRICS_ENABLED": val}, clear=True): + cfg = DeployConfig.from_env() + assert cfg.metrics_enabled is True, f"Failed for {val!r}" + + for val in ("false", "0", "no", ""): + with patch.dict(os.environ, {"OPENSPACE_METRICS_ENABLED": val}, clear=True): + cfg = DeployConfig.from_env() + assert cfg.metrics_enabled is False, f"Failed for {val!r}" + + def test_to_dict_excludes_secrets(self): + """Serialization must not leak API keys.""" + from openspace.deploy.config import DeployConfig + + cfg = DeployConfig() + d = cfg.to_safe_dict() + # Ensure no secret fields are exposed + for key in d: + assert "key" not in key.lower() or "api" not in key.lower() + assert "token" not in key.lower() + assert "secret" not in key.lower() + + def test_frozen_config(self): + """Config should be immutable after creation.""" + from openspace.deploy.config import DeployConfig + + cfg = DeployConfig() + with pytest.raises((AttributeError, TypeError)): + cfg.mcp_port = 1234 # type: ignore[misc] + + +# ═══════════════════════════════════════════════════════════════════════ +# Graceful shutdown +# ═══════════════════════════════════════════════════════════════════════ + + +class TestGracefulShutdown: + """Signal-aware graceful shutdown handler.""" + + def test_shutdown_handler_importable(self): + from openspace.deploy.shutdown import GracefulShutdownHandler + + assert GracefulShutdownHandler is not None + + @pytest.mark.asyncio + async def test_shutdown_runs_hooks(self): + """Shutdown must execute all registered hooks.""" + from openspace.deploy.shutdown import GracefulShutdownHandler + + handler = GracefulShutdownHandler(timeout=5) + hook1 = AsyncMock() + hook2 = AsyncMock() + handler.register_hook(hook1) + handler.register_hook(hook2) + + await handler.shutdown() + hook1.assert_awaited_once() + hook2.assert_awaited_once() + + @pytest.mark.asyncio + async def test_shutdown_respects_timeout(self): + """Hooks exceeding timeout must be cancelled.""" + from openspace.deploy.shutdown import GracefulShutdownHandler + + handler = GracefulShutdownHandler(timeout=1) + + async def slow_hook(): + await asyncio.sleep(100) + + handler.register_hook(slow_hook) + # Should complete within ~2 seconds (1s timeout + buffer), not hang + await asyncio.wait_for(handler.shutdown(), timeout=5) + + @pytest.mark.asyncio + async def test_shutdown_continues_on_hook_failure(self): + """One failing hook must not prevent others from running.""" + from openspace.deploy.shutdown import GracefulShutdownHandler + + handler = GracefulShutdownHandler(timeout=5) + + async def failing_hook(): + raise RuntimeError("hook failed") + + success_hook = AsyncMock() + handler.register_hook(failing_hook) + handler.register_hook(success_hook) + + await handler.shutdown() + success_hook.assert_awaited_once() + + @pytest.mark.asyncio + async def test_shutdown_idempotent(self): + """Multiple shutdown calls must not re-run hooks.""" + from openspace.deploy.shutdown import GracefulShutdownHandler + + handler = GracefulShutdownHandler(timeout=5) + hook = AsyncMock() + handler.register_hook(hook) + + await handler.shutdown() + await handler.shutdown() # second call is no-op + hook.assert_awaited_once() + + @pytest.mark.asyncio + async def test_shutdown_drains_in_flight(self): + """Shutdown must wait for tracked in-flight tasks.""" + from openspace.deploy.shutdown import GracefulShutdownHandler + + handler = GracefulShutdownHandler(timeout=5) + completed = False + + async def in_flight_task(): + nonlocal completed + await asyncio.sleep(0.5) + completed = True + + handler.track_task(asyncio.create_task(in_flight_task())) + await handler.shutdown() + assert completed, "In-flight task was not drained before shutdown" + + @pytest.mark.asyncio + async def test_drain_survives_task_exception(self): + """A throwing in-flight task must not crash shutdown.""" + from openspace.deploy.shutdown import GracefulShutdownHandler + + handler = GracefulShutdownHandler(timeout=5) + success_hook = AsyncMock() + handler.register_hook(success_hook) + + async def exploding_task(): + raise RuntimeError("task boom") + + handler.track_task(asyncio.create_task(exploding_task())) + await handler.shutdown() + success_hook.assert_awaited_once() + + @pytest.mark.asyncio + async def test_track_task_during_shutdown_auto_cancels(self): + """Tasks tracked after shutdown starts are auto-cancelled.""" + from openspace.deploy.shutdown import GracefulShutdownHandler + + handler = GracefulShutdownHandler(timeout=5) + await handler.shutdown() + + # Now try to track a new task — should be cancelled + task = asyncio.create_task(asyncio.sleep(100)) + handler.track_task(task) + await asyncio.sleep(0.1) + assert task.cancelled() + + @pytest.mark.asyncio + async def test_shutdown_with_no_hooks_no_tasks(self): + """Empty shutdown completes without error.""" + from openspace.deploy.shutdown import GracefulShutdownHandler + + handler = GracefulShutdownHandler(timeout=5) + await handler.shutdown() # should not raise + + def test_shutdown_timeout_minimum(self): + """timeout < 1 is rejected.""" + from openspace.deploy.shutdown import GracefulShutdownHandler + + with pytest.raises(ValueError, match="timeout"): + GracefulShutdownHandler(timeout=0) + + +# ═══════════════════════════════════════════════════════════════════════ +# Dockerfile +# ═══════════════════════════════════════════════════════════════════════ + + +class TestDockerfile: + """Dockerfile structure and correctness.""" + + def test_dockerfile_exists(self): + assert (ROOT / "Dockerfile").is_file() + + def test_dockerfile_multi_stage(self): + content = (ROOT / "Dockerfile").read_text() + # Must have at least 2 FROM instructions (build + runtime) + from_count = sum(1 for line in content.splitlines() if line.strip().startswith("FROM ")) + assert from_count >= 2, "Dockerfile should be multi-stage" + + def test_dockerfile_non_root_user(self): + content = (ROOT / "Dockerfile").read_text() + # Must be a Dockerfile instruction, not a comment + lines = content.splitlines() + user_lines = [l for l in lines if l.strip().startswith("USER ")] + assert user_lines, "Dockerfile must have USER instruction (not in comment)" + + def test_dockerfile_shutdown_timeout_under_docker_default(self): + """Dockerfile OPENSPACE_SHUTDOWN_TIMEOUT must be < Docker's 10s default.""" + content = (ROOT / "Dockerfile").read_text() + import re + + match = re.search(r"OPENSPACE_SHUTDOWN_TIMEOUT=(\d+)", content) + assert match, "Dockerfile must set OPENSPACE_SHUTDOWN_TIMEOUT" + timeout = int(match.group(1)) + assert timeout < 10, ( + f"Shutdown timeout {timeout}s must be < Docker's 10s stop_grace_period" + ) + + def test_dockerfile_healthcheck(self): + content = (ROOT / "Dockerfile").read_text() + lines = content.splitlines() + hc_lines = [l for l in lines if l.strip().startswith("HEALTHCHECK")] + assert hc_lines, "Dockerfile must have HEALTHCHECK instruction" + + def test_dockerfile_healthcheck_hits_real_endpoint(self): + """HEALTHCHECK must hit /health HTTP endpoint, not just import.""" + content = (ROOT / "Dockerfile").read_text() + assert "localhost:8000/health" in content, ( + "HEALTHCHECK must hit real /health endpoint for accurate checks" + ) + + def test_dockerfile_no_secrets(self): + """Dockerfile must not contain hardcoded secrets.""" + content = (ROOT / "Dockerfile").read_text().lower() + for pattern in ["api_key=", "secret=", "password=", "token="]: + assert pattern not in content, f"Dockerfile contains hardcoded secret: {pattern}" + + def test_dockerignore_exists(self): + assert (ROOT / ".dockerignore").is_file() + + def test_dockerignore_excludes_tests(self): + content = (ROOT / ".dockerignore").read_text() + assert "tests" in content or "tests/" in content + + +# ═══════════════════════════════════════════════════════════════════════ +# Entrypoint +# ═══════════════════════════════════════════════════════════════════════ + + +class TestEntrypoint: + """Application entrypoint wiring.""" + + def test_deploy_package_importable(self): + import openspace.deploy + + assert hasattr(openspace.deploy, "__name__") + + def test_config_from_env_integration(self): + """Config → server wiring.""" + from openspace.deploy.config import DeployConfig + + cfg = DeployConfig() + assert hasattr(cfg, "mcp_host") + assert hasattr(cfg, "mcp_port") + assert hasattr(cfg, "mcp_transport") + assert hasattr(cfg, "shutdown_timeout") + + def test_deploy_config_wired_into_server(self): + """run_mcp_server imports DeployConfig (not dead code).""" + import inspect + + from openspace.mcp.server import run_mcp_server + + source = inspect.getsource(run_mcp_server) + assert "DeployConfig" in source, "DeployConfig must be used in run_mcp_server" + + def test_health_endpoint_outside_auth(self): + """/health must be accessible WITHOUT bearer auth for K8s probes.""" + import inspect + + from openspace.mcp.server import run_mcp_server + + source = inspect.getsource(run_mcp_server) + # /health route must be mounted BEFORE auth middleware in ASGI chain + health_pos = source.find("Route(\"/health\"") + bearer_pos = source.find("BearerTokenMiddleware") + assert health_pos > 0, "/health route must exist" + assert bearer_pos > 0, "BearerTokenMiddleware must exist" + # In the code, auth wraps the MCP app; health wraps auth+MCP at top level + # So health_endpoint definition comes AFTER protected_app is built + assert health_pos > bearer_pos, ( + "/health must be outside auth — route should wrap protected_app" + ) + + def test_shutdown_runs_after_uvicorn(self): + """GracefulShutdownHandler.shutdown() must run after server.serve().""" + import inspect + + from openspace.mcp.server import run_mcp_server + + source = inspect.getsource(run_mcp_server) + assert "serve_with_shutdown" in source, ( + "Shutdown must be wired via serve_with_shutdown wrapper" + ) + assert "finally:" in source, ( + "Shutdown must run in finally block to guarantee execution" + ) + + +# ═══════════════════════════════════════════════════════════════════════ +# Package completeness +# ═══════════════════════════════════════════════════════════════════════ + + +class TestDeployPackageCompleteness: + def test_all_modules_importable(self): + import openspace.deploy.config + import openspace.deploy.shutdown + + assert True + + def test_tool_count_unchanged(self): + """Deployment must not change MCP tool count.""" + from unittest.mock import MagicMock + + from openspace.mcp.tool_handlers import register_handlers + + mock_mcp = MagicMock() + register_handlers(mock_mcp) + assert mock_mcp.tool.call_count == 8