118 lines
4.4 KiB
Python
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()
|