Files
LumaOps-Public/lumaops/backend/src/lumaops_backend/config.py
T
2026-09-30 00:26:45 +00:00

118 lines
4.4 KiB
Python

"""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()