import pathlib from typing import Any, Literal, Self import sqlalchemy as sa from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import serialization from fansifter_common.adapters.aws.secretsmanager import SecretsManager from fansifter_common.adapters.snowflake.converter import SnowflakeConverter from fansifter_common.constants import PROD_ENVIRONMENT, QA_ENVIRONMENT from fansifter_common.utils.functional import lazy_proxy from pydantic import ( Field, SecretStr, ValidationError, model_validator, ) from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): environment: str debug: bool = Field(validation_alias="APP_DEBUG", default=False) service_name: str = "lambda-audience-text-campaigns-sender" service_version: str = "1.0.0" # AWS region configuration aws_region_name: str = "us-east-1" # Directories base_dir: pathlib.Path = pathlib.Path(__file__).resolve().parent @property def m2m_token_secret_key_name(self) -> str: return f"{self.environment}/lambda-audience-refresh-m2m-token/m2m_token" @property def m2m_token_secret_expiry_key_name(self) -> str: return ( f"{self.environment}/lambda-audience-refresh-m2m-token/m2m_token_expiration" ) # Snowflake settings snowflake_account: str = "account" snowflake_database: str = "database" snowflake_schema: str = "schema" snowflake_warehouse: str = "warehouse" snowflake_role: str = "role" snowflake_user: str = "user" snowflake_password: SecretStr | None = None snowflake_port: int = 443 snowflake_url: str = "" @model_validator(mode="after") def _setup_snowflake_url(self) -> Any: from snowflake.sqlalchemy import URL self.snowflake_url = URL( account=self.snowflake_account, user=self.snowflake_user, database=self.snowflake_database, schema=self.snowflake_schema, warehouse=self.snowflake_warehouse, role=self.snowflake_role, client_session_keep_alive=True, ) return self # Snowflake pool settings snowflake_echo: bool = False snowflake_pool_size: int = 10 snowflake_pool_max_overflow: int = 20 snowflake_pool_recycle: int = 4 * 55 * 60 # ~4 hours snowflake_pool_pre_ping: bool = False snowflake_pool_reset_on_return: Literal["commit", "rollback"] | None = None default_snowflake_private_key_path: pathlib.Path = ( pathlib.Path.home() / ".ssh/snowflake/rsa_key.p8" ) snowflake_private_key_path: pathlib.Path = default_snowflake_private_key_path snowflake_private_key: bytes = b"" snowflake_key_passphrase: bytes = b"" # Snowflake private key secrets snowflake_private_key_secret_name: str = "private_key_secret_name" snowflake_key_passphrase_secret_name: str = "key_passphrase_secret_name" snowflake_connect_args: dict[str, Any] = { "converter_class": SnowflakeConverter, } @model_validator(mode="after") def _setup_snowflake_connect_args(self) -> Self: if self.environment == "test": return self # Load private key if not set and path exists if not self.snowflake_private_key and self.snowflake_private_key_path.exists(): self.snowflake_private_key = self.snowflake_private_key_path.read_bytes() # Set private key and passphrase from secrets manager in QA and PROD environments if self.environment in (QA_ENVIRONMENT, PROD_ENVIRONMENT): secrets_manager = SecretsManager(region_name=self.aws_region_name) self.snowflake_private_key = secrets_manager.get_secret( self.snowflake_private_key_secret_name ).encode() self.snowflake_key_passphrase = secrets_manager.get_secret( self.snowflake_key_passphrase_secret_name ).encode() # Load private key if self.snowflake_private_key: pem_private_key = serialization.load_pem_private_key( self.snowflake_private_key, password=self.snowflake_key_passphrase if self.snowflake_key_passphrase else None, backend=default_backend(), ) private_key = pem_private_key.private_bytes( encoding=serialization.Encoding.DER, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption(), ) self.snowflake_connect_args["private_key"] = private_key return self pg_host: str = Field(default="localhost", validation_alias="POSTGRES_HOST") pg_port: int = Field(default=5432, validation_alias="POSTGRES_PORT") pg_user: str = Field(default="postgres", validation_alias="POSTGRES_USER") pg_password: SecretStr = Field( default=SecretStr("postgres"), validation_alias="POSTGRES_PASSWORD" ) pg_password_secret_name: str = Field( default="", validation_alias="POSTGRES_PASSWORD_SECRET_NAME" ) pg_database: str = Field(default="dmp", validation_alias="POSTGRES_NAME") @model_validator(mode="after") def _setup_pg_password(self) -> Self: if self.environment in (QA_ENVIRONMENT, PROD_ENVIRONMENT): secrets_manager = SecretsManager(region_name=self.aws_region_name) self.pg_password = SecretStr( secrets_manager.get_secret(self.pg_password_secret_name) ) return self @property def pg_url(self) -> sa.URL: return sa.URL.create( drivername="postgresql+psycopg", username=self.pg_user, password=self.pg_password.get_secret_value(), host=self.pg_host, port=self.pg_port, database=self.pg_database, ) pg_pool_size: int = 5 pg_pool_max_overflow: int = 10 pg_pool_recycle: int = 4 * 60 * 60 # ~4 hours # KMS kms_key_id: str = Field(default="", validation_alias="KMS_KEY_ID") @model_validator(mode="after") def _setup_kms_key_id(self) -> Self: if not self.kms_key_id and self.environment in [ QA_ENVIRONMENT, PROD_ENVIRONMENT, ]: raise ValueError(f"`kms_key_id` is not configured in {self.environment}.") return self # Redis redis_host: str = "localhost" redis_port: int = 6379 redis_db: int = 0 redis_ssl: bool = False redis_sender_lock_key: str = "text-campaigns-sender:lock:batch:{batch_id}" @model_validator(mode="after") def _setup_redis_ssl(self) -> Self: if self.environment in [QA_ENVIRONMENT, PROD_ENVIRONMENT]: self.redis_ssl = True return self # Sender drain loop sender_chunk_size: int = Field(default=100, validation_alias="SENDER_CHUNK_SIZE") sender_concurrency: int = Field(default=10, validation_alias="SENDER_CONCURRENCY") sender_message_max_retries: int = Field( default=1, validation_alias="SENDER_MESSAGE_MAX_RETRIES" ) sender_time_budget_seconds: int = Field( default=800, validation_alias="SENDER_TIME_BUDGET_SECONDS" ) safe_until_buffer_seconds: int = Field( default=90, validation_alias="SAFE_UNTIL_BUFFER_SECONDS" ) # Sender sender_backend: Literal["twilio", "log"] = Field( default="twilio", validation_alias="SENDER_BACKEND" ) @model_validator(mode="after") def _setup_sender_backend(self) -> Self: if self.environment == PROD_ENVIRONMENT: self.sender_backend = "log" return self allowed_recipient_phone_numbers: list[str] = Field( default=[], validation_alias="ALLOWED_RECIPIENT_PHONE_NUMBERS" ) @model_validator(mode="after") def _setup_allowed_recipient_phone_numbers(self) -> Self: if self.environment == QA_ENVIRONMENT: self.allowed_recipient_phone_numbers = [ "+1XXXXXXXXXX", # dummy ] return self # Logging logging_debug: bool = Field(validation_alias="LOGGING_DEBUG", default=False) @property def logging_config(self) -> dict[str, Any]: return { "version": 1, "disable_existing_loggers": True, "formatters": { "json": { "()": "owslogger.logger.DDJsonFormatter", "service_name": self.service_name, "service_version": self.service_version, "env": self.environment, }, "console": { "()": "fansifter_common.logging.ConsoleFormatter", }, }, "filters": { "require_debug_true": { "()": "fansifter_common.logging.RequireDebugTrueFilter", "value": self.logging_debug, }, "require_debug_false": { "()": "fansifter_common.logging.RequireDebugFalseFilter", "value": self.logging_debug, }, }, "handlers": { "stream": { "level": "INFO", "class": "logging.StreamHandler", "formatter": "json", "filters": ["require_debug_false"], }, "rich": { "level": "INFO", "class": "rich.logging.RichHandler", "filters": ["require_debug_true"], "formatter": "console", }, }, "loggers": { "app": { "handlers": ["stream", "rich"], "level": "INFO", "propagate": False, }, "httpx": { "handlers": ["stream", "rich"], "level": "WARNING", "propagate": False, }, "sqlalchemy.engine": { "handlers": ["stream", "rich"], "level": "WARNING", "propagate": False, }, }, } # Sentry sentry_dsn: str | None = None @model_validator(mode="after") def _setup_sentry_dsn(self) -> Self: if not self.sentry_dsn and self.environment in [ QA_ENVIRONMENT, PROD_ENVIRONMENT, ]: raise ValueError(f"Sentry is not configured in {self.environment}.") return self model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="allow", ) def initialize_settings(**defaults: Any) -> Settings: try: return Settings(**defaults) except ValidationError as exc: errors = "\n".join( [ f" * {'.'.join(map(str, error['loc']))} - {error['msg']}" for error in exc.errors() ] ) raise RuntimeError(f"Failed to initialize settings:\n {errors}") from exc # Lazy proxy for settings settings = lazy_proxy(initialize_settings)