|
| 1 | +"""Centralized environment-based configuration for OpenSpace deployments. |
| 2 | +
|
| 3 | +All deployment-relevant settings are read from environment variables with |
| 4 | +sensible defaults. Configuration is frozen (immutable) after creation. |
| 5 | +
|
| 6 | +Environment variables: |
| 7 | + OPENSPACE_MCP_HOST MCP server bind address (default: 0.0.0.0) |
| 8 | + OPENSPACE_MCP_PORT MCP server port (default: 8000) |
| 9 | + OPENSPACE_MCP_TRANSPORT Transport: stdio|sse|streamable-http (default: stdio) |
| 10 | + OPENSPACE_LOG_LEVEL Log level: DEBUG|INFO|WARNING|ERROR (default: INFO) |
| 11 | + OPENSPACE_SHUTDOWN_TIMEOUT Graceful shutdown timeout in seconds (default: 30) |
| 12 | + OPENSPACE_METRICS_ENABLED Enable Prometheus metrics (default: true) |
| 13 | + OPENSPACE_SKILL_STORE_PATH Path to skill store directory (default: skills/) |
| 14 | + OPENSPACE_DEBUG Enable debug mode (default: false) |
| 15 | +
|
| 16 | +Usage:: |
| 17 | +
|
| 18 | + from openspace.deploy.config import DeployConfig |
| 19 | +
|
| 20 | + cfg = DeployConfig.from_env() |
| 21 | + print(cfg.mcp_port) # 8000 |
| 22 | +""" |
| 23 | + |
| 24 | +from __future__ import annotations |
| 25 | + |
| 26 | +import os |
| 27 | +from dataclasses import dataclass |
| 28 | +from typing import Any, Dict |
| 29 | + |
| 30 | +_VALID_TRANSPORTS = ("stdio", "sse", "streamable-http") |
| 31 | +_VALID_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL") |
| 32 | + |
| 33 | + |
| 34 | +def _int_env(name: str, default: int) -> int: |
| 35 | + """Parse an integer environment variable with graceful fallback.""" |
| 36 | + raw = os.environ.get(name, "").strip() |
| 37 | + if not raw: |
| 38 | + return default |
| 39 | + try: |
| 40 | + return int(raw) |
| 41 | + except ValueError: |
| 42 | + raise ValueError( |
| 43 | + f"Environment variable {name}={raw!r} is not a valid integer" |
| 44 | + ) from None |
| 45 | + |
| 46 | + |
| 47 | +@dataclass(frozen=True) |
| 48 | +class DeployConfig: |
| 49 | + """Immutable deployment configuration. |
| 50 | +
|
| 51 | + All values have sane defaults and can be overridden via environment |
| 52 | + variables using :meth:`from_env`. |
| 53 | + """ |
| 54 | + |
| 55 | + mcp_host: str = "0.0.0.0" |
| 56 | + mcp_port: int = 8000 |
| 57 | + mcp_transport: str = "stdio" |
| 58 | + log_level: str = "INFO" |
| 59 | + shutdown_timeout: int = 30 |
| 60 | + metrics_enabled: bool = True |
| 61 | + skill_store_path: str = "skills/" |
| 62 | + debug: bool = False |
| 63 | + |
| 64 | + def __post_init__(self) -> None: |
| 65 | + if not (1 <= self.mcp_port <= 65535): |
| 66 | + raise ValueError( |
| 67 | + f"port must be 1-65535, got {self.mcp_port}" |
| 68 | + ) |
| 69 | + if self.mcp_transport not in _VALID_TRANSPORTS: |
| 70 | + raise ValueError( |
| 71 | + f"transport must be one of {_VALID_TRANSPORTS}, " |
| 72 | + f"got {self.mcp_transport!r}" |
| 73 | + ) |
| 74 | + # Normalize log_level to uppercase |
| 75 | + normalized = self.log_level.upper() |
| 76 | + if normalized not in _VALID_LOG_LEVELS: |
| 77 | + raise ValueError( |
| 78 | + f"log_level must be one of {_VALID_LOG_LEVELS}, " |
| 79 | + f"got {self.log_level!r}" |
| 80 | + ) |
| 81 | + if normalized != self.log_level: |
| 82 | + object.__setattr__(self, "log_level", normalized) |
| 83 | + if self.shutdown_timeout < 1: |
| 84 | + raise ValueError( |
| 85 | + f"shutdown_timeout must be >= 1, got {self.shutdown_timeout}" |
| 86 | + ) |
| 87 | + |
| 88 | + @classmethod |
| 89 | + def from_env(cls) -> DeployConfig: |
| 90 | + """Create config from environment variables. |
| 91 | +
|
| 92 | + Handles empty strings, whitespace, and non-integer values |
| 93 | + gracefully with descriptive error messages. |
| 94 | + """ |
| 95 | + return cls( |
| 96 | + mcp_host=os.environ.get("OPENSPACE_MCP_HOST", "0.0.0.0").strip() or "0.0.0.0", |
| 97 | + mcp_port=_int_env("OPENSPACE_MCP_PORT", 8000), |
| 98 | + mcp_transport=os.environ.get("OPENSPACE_MCP_TRANSPORT", "stdio").strip() or "stdio", |
| 99 | + log_level=(os.environ.get("OPENSPACE_LOG_LEVEL", "INFO").strip() or "INFO").upper(), |
| 100 | + shutdown_timeout=_int_env("OPENSPACE_SHUTDOWN_TIMEOUT", 30), |
| 101 | + metrics_enabled=os.environ.get( |
| 102 | + "OPENSPACE_METRICS_ENABLED", "true" |
| 103 | + ).strip().lower() |
| 104 | + in ("true", "1", "yes"), |
| 105 | + skill_store_path=os.environ.get( |
| 106 | + "OPENSPACE_SKILL_STORE_PATH", "skills/" |
| 107 | + ).strip() or "skills/", |
| 108 | + debug=os.environ.get("OPENSPACE_DEBUG", "false").strip().lower() |
| 109 | + in ("true", "1", "yes"), |
| 110 | + ) |
| 111 | + |
| 112 | + def to_safe_dict(self) -> Dict[str, Any]: |
| 113 | + """Serialize config without secrets. |
| 114 | +
|
| 115 | + Returns only deployment-relevant fields. API keys, tokens, |
| 116 | + and other secrets are never included. |
| 117 | + """ |
| 118 | + return { |
| 119 | + "mcp_host": self.mcp_host, |
| 120 | + "mcp_port": self.mcp_port, |
| 121 | + "mcp_transport": self.mcp_transport, |
| 122 | + "log_level": self.log_level, |
| 123 | + "shutdown_timeout": self.shutdown_timeout, |
| 124 | + "metrics_enabled": self.metrics_enabled, |
| 125 | + "skill_store_path": self.skill_store_path, |
| 126 | + "debug": self.debug, |
| 127 | + } |
0 commit comments