"""Config file for lambda function.""" from __future__ import annotations import logging import os from dataclasses import dataclass, field, replace from enum import StrEnum from typing import Final from dotenv import load_dotenv from secrets_manager.lambda_ext import LambdaSecretsManager # Load environment variables from a .env file if present load_dotenv(override=True) class Environment(StrEnum): """Deployment environments with local/managed detection properties.""" DEV = 'dev' QA = 'qa' UAT = 'uat' PROD = 'prod' @property def is_local(self) -> bool: """Check if in a local development environment.""" return self == Environment.DEV @property def is_managed(self) -> bool: """Check if in a managed (QA, UAT, Prod) environment.""" return self in (Environment.QA, Environment.UAT, Environment.PROD) @dataclass(frozen=True) class MySQLSettings: """MySQL connection settings.""" host: str = os.environ.get('MYSQL_DB_HOST', '') user: str = os.environ.get('MYSQL_DB_USER', '') password: str = field(default=os.environ.get('MYSQL_DB_PASS', ''), repr=False) database: str = os.environ.get('MYSQL_DB_NAME', 'royalty_accounting') port: int = int(os.environ.get('MYSQL_DB_PORT', '3306')) @dataclass(frozen=True) class SAPSettings: """SAP service endpoint settings.""" API: Final[str] = os.environ.get('SAP_API', 'https://connect-tsme.sonymusic.com') ENV: Final[str] = os.environ.get('SAP_ENV', '5000') @dataclass(frozen=True) class BatchSettings: """Batch mode processing limits.""" MAX_BATCH_SIZE: int = int(os.environ.get('MAX_BATCH_SIZE', '250')) # A sync is stale when the contract was modified at least this many # minutes after the sap_sync state was last updated. STALE_SYNC_MINUTES: int = int(os.environ.get('STALE_SYNC_MINUTES', '5')) @dataclass class AppConfig: """App configuration.""" app_name: Final[str] = 'lambda-abacus-sync-contract-sap' aws_region: Final[str] = os.environ.get('AWS_REGION', 'us-east-1') env: Environment = Environment(os.environ.get('ENVIRONMENT', Environment.DEV)) log_level: int = field(default_factory=lambda: logging.INFO) sentry_dsn: str | None = os.environ.get('SENTRY_DSN') batch: BatchSettings = field(default_factory=BatchSettings) sap: SAPSettings = field(default_factory=SAPSettings) mysql: MySQLSettings = field(init=False) def __post_init__(self) -> None: """Initialize settings and override with secrets for managed environments.""" self.log_level = self._get_log_level() self.mysql = MySQLSettings() if self.env.is_managed: self._apply_secrets() def _get_log_level(self) -> int: """Safely convert LOGGING_LEVEL env var to a numeric logging constant.""" raw_level = os.environ.get('LOGGING_LEVEL', 'INFO').upper() if raw_level.isdigit(): return int(raw_level) return getattr(logging, raw_level, logging.INFO) def _apply_secrets(self) -> None: """Fetch credentials from AWS Secrets Manager for higher environments.""" client = LambdaSecretsManager( environment=self.env.value, service_name=self.app_name ) self.sentry_dsn = client.get_cred('SENTRY_DSN') self.mysql = replace(self.mysql, password=client.get_cred('MYSQL_DB_PASS')) def validate_config(cfg: AppConfig) -> None: """Validate configuration values on startup to fail fast. Raises: ValueError: If any configuration value is logically invalid. """ errors = [] # Batch: at the observed ~40-45 contracts/minute, 500 is the most that # safely fits in the 15-minute Lambda timeout. if not (50 <= cfg.batch.MAX_BATCH_SIZE <= 500): errors.append( f'batch.MAX_BATCH_SIZE must be [50, 500], got {cfg.batch.MAX_BATCH_SIZE}' ) if not (1 <= cfg.batch.STALE_SYNC_MINUTES <= 240): errors.append( f'batch.STALE_SYNC_MINUTES must be [1, 240], got {cfg.batch.STALE_SYNC_MINUTES}' ) # SAP if not cfg.sap.API.startswith('https://'): errors.append(f'sap.API must be an https URL, got {cfg.sap.API}') if not cfg.sap.ENV.isdigit(): errors.append(f'sap.ENV must be a numeric port, got {cfg.sap.ENV}') # MySQL if not (1 <= cfg.mysql.port <= 65535): errors.append(f'mysql.port must be [1, 65535], got {cfg.mysql.port}') if cfg.env.is_managed: if not cfg.mysql.host: errors.append('mysql.host is required in managed environments') if not cfg.mysql.user: errors.append('mysql.user is required in managed environments') if not cfg.mysql.password: errors.append('mysql.password is empty after applying secrets') if errors: raise ValueError( 'Configuration validation failed:\n' + '\n'.join(f' - {e}' for e in errors) ) config = AppConfig() validate_config(config)