"""Application configuration with deployment-safe validation.""" from __future__ import annotations from functools import lru_cache from pathlib import Path from typing import Literal from pydantic import Field, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): """Runtime configuration sourced from environment variables.""" model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore", ) app_name: str = "LumaOps" app_version: str = "0.1.0" app_host: str = "0.0.0.0" app_port: int = Field(default=8080, ge=1, le=65535) app_base_url: str = "" environment: Literal["development", "test", "production"] = Field( default="production", alias="LUMAOPS_ENV" ) log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO" timezone: str = Field(default="Europe/Brussels", alias="TZ") openrgb_host: str = "127.0.0.1" openrgb_port: int = Field(default=6742, ge=1, le=65535) openrgb_config_dir: Path = Path("/config/openrgb") openrgb_connect_timeout: float = Field(default=2.0, gt=0, le=30) openrgb_command_timeout: float = Field(default=5.0, gt=0, le=60) openrgb_max_packet_size: int = Field(default=16 * 1024 * 1024, ge=1024, le=64 * 1024 * 1024) config_dir: Path = Path("/config/lumaops") data_dir: Path = Path("/data") logs_dir: Path = Path("/logs") static_dir: Path = Path("/opt/lumaops/static") database_url: str = "sqlite:////data/lumaops.db" secret_key: str | None = Field(default=None, alias="LUMAOPS_SECRET_KEY") enable_network_discovery: bool = True enable_home_assistant: bool = False enable_wled: bool = False connector_mode: Literal["openrgb", "mock"] = "openrgb" auth_enabled: bool = True admin_token: str | None = Field(default=None, alias="LUMAOPS_ADMIN_TOKEN") external_access: bool = False trusted_proxies: str = "" cors_origins: str = "" secure_cookies: bool = True command_rate_per_second: float = Field(default=10.0, gt=0, le=60) realtime_rate_per_second: float = Field(default=30.0, gt=0, le=120) command_timeout_seconds: float = Field(default=10.0, gt=0, le=120) health_retention_days: int = Field(default=30, ge=1, le=3650) @field_validator("openrgb_host") @classmethod def validate_openrgb_host(cls, value: str) -> str: value = value.strip() if not value: raise ValueError("OPENRGB_HOST mag niet leeg zijn") return value @model_validator(mode="after") def validate_security_invariants(self) -> Settings: if self.environment == "production" and self.connector_mode == "mock": raise ValueError("MockOpenRGBAdapter is verboden in productie") if self.external_access and not self.auth_enabled: raise ValueError("AUTH_ENABLED is verplicht wanneer EXTERNAL_ACCESS=true") if self.environment == "production" and self.app_host not in { "127.0.0.1", "::1", "localhost", } and not self.auth_enabled: raise ValueError("AUTH_ENABLED is verplicht bij een niet-lokale productiebind") if self.auth_enabled: token = (self.admin_token or "").strip() placeholders = { "replace-with-a-long-random-token", "change-me", "changeme", } if len(token) < 32 or token.lower() in placeholders: raise ValueError( "LUMAOPS_ADMIN_TOKEN moet minstens 32 tekens lang en niet voorspelbaar zijn" ) return self @property def database_path(self) -> Path: prefix = "sqlite:///" if not self.database_url.startswith(prefix): raise ValueError("LumaOps MVP ondersteunt uitsluitend sqlite:/// DATABASE_URL waarden") raw = self.database_url[len(prefix) :] return Path(raw if raw else "lumaops.db").expanduser().resolve() @property def trusted_proxy_list(self) -> tuple[str, ...]: return tuple(part.strip() for part in self.trusted_proxies.split(",") if part.strip()) @property def cors_origin_list(self) -> tuple[str, ...]: return tuple(part.strip() for part in self.cors_origins.split(",") if part.strip()) @lru_cache(maxsize=1) def get_settings() -> Settings: return Settings()