from pydantic import Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict from .timeutil import validate_time_zone class Settings(BaseSettings): model_config = SettingsConfigDict(env_prefix="MONLET_", env_file=".env", extra="ignore") auth_token: str database_url: str = "postgresql+asyncpg://monlet:monlet@127.0.0.1:5432/monlet" log_level: str = "INFO" body_limit_bytes: int = 1_048_576 output_max_bytes: int = 8192 max_batch_events: int = 200 stale_after_sec: int = 20 dead_after_sec: int = 30 detector_tick_sec: int = 5 host: str = "0.0.0.0" port: int = 8000 enable_detector: bool = True enable_notifier_worker: bool = True events_retention_months: int = 36 events_future_partitions: int = 3 event_dedup_retention_days: int = 30 event_dedup_prune_batch_size: int = 5000 max_pending_agents: int = 10_000 pending_agent_ttl_days: int = 7 pending_agent_prune_batch_size: int = 1000 partition_maintenance_interval_sec: int = 3600 outbox_retention_max_rows: int = 50_000 # PH-012: bounded prune per maintenance tick to avoid one huge DELETE. outbox_prune_batch_size: int = 5000 # PH-016: explicit DB pool sizing for production tuning. db_pool_size: int = 10 db_max_overflow: int = 10 db_pool_timeout_sec: float = 30.0 db_pool_recycle_sec: int = 1800 notifier_tick_sec: int = 5 notifier_batch_size: int = 20 notifier_max_attempts: int = 8 notifier_lease_sec: int = 300 notifier_concurrency: int = Field(default=4, ge=1) notifier_http_timeout_sec: float = 10.0 notification_time_zone: str = "UTC" # PH-020: notifier output policy. include controls whether output text is # sent at all; max_bytes truncates it before delivery (after redaction). notifier_include_output: bool = True notifier_max_output_bytes: int = 1024 notifier_debug_enabled: bool = True notifier_telegram_enabled: bool = False notifier_telegram_token: str = "" notifier_telegram_chat_id: str = "" notifier_webhook_enabled: bool = False notifier_webhook_url: str = "" notifier_webhook_token: str = "" notifier_alertmanager_enabled: bool = False notifier_alertmanager_url: str = "" @staticmethod def _validate_token_list(value: str, env_name: str) -> str: if not value or value == "changeme": raise ValueError(f"{env_name} must be set to a non-default value") tokens = [t for t in value.replace(",", " ").split() if t] if not tokens: raise ValueError(f"{env_name} must contain at least one token") if any(t == "changeme" for t in tokens): raise ValueError(f"{env_name} must be set to a non-default value") return value @field_validator("auth_token") @classmethod def _validate_auth_token(cls, value: str) -> str: return cls._validate_token_list(value, "MONLET_AUTH_TOKEN") @property def auth_tokens(self) -> list[str]: """Active Bearer tokens (>=1). Multiple tokens enable overlap rotation.""" return [t for t in self.auth_token.replace(",", " ").split() if t] @field_validator("notification_time_zone") @classmethod def _validate_notification_time_zone(cls, value: str) -> str: return validate_time_zone(value) @property def enabled_notifiers(self) -> list[str]: out: list[str] = [] if self.notifier_debug_enabled: out.append("debug") if ( self.notifier_telegram_enabled and self.notifier_telegram_token and self.notifier_telegram_chat_id ): out.append("telegram") if self.notifier_webhook_enabled and self.notifier_webhook_url: out.append("webhook") if self.notifier_alertmanager_enabled and self.notifier_alertmanager_url: out.append("alertmanager") return out @property def sync_database_url(self) -> str: return self.database_url.replace("+asyncpg", "+psycopg") _settings: Settings | None = None def get_settings() -> Settings: global _settings if _settings is None: _settings = Settings() return _settings def reset_settings_cache() -> None: global _settings _settings = None