Módulo 6: Cloud Migration Patterns

3. Config Management Multi-Entorno

Descripción

En esta cápsula vas a construir un sistema de configuración robusto que maneja tres entornos (development/LocalStack, staging/AWS, production/AWS) con tipado, validación, y separación de secrets. En la cápsula anterior abstraíste el entorno. Ahora vas a darle estructura a esa abstracción: Pydantic Settings para tipar y validar, archivos .env por entorno, reglas de validación que impiden configuraciones inválidas, y un patrón de secrets management que no expone credenciales en archivos de configuración.

Contexto: Variables de entorno para 2 entornos parecen simples. Para 3+ entornos (dev, staging, prod) con diferentes credenciales, endpoints, buckets, feature flags, y secrets, el config management necesita estructura. "Pon un .env" no es un patrón — es el primer paso. El patrón completo incluye tipado (no leer strings que deberían ser ints), validación (no aceptar un ENVIRONMENT=produccion si el valor válido es production), defaults coherentes, y separación de secrets (no guardar AWS keys en git).


Por Qué Config Management Importa

El costo de config mal gestionada

Escenario real — lo que pasa sin config management:

1. Developer cambia ENVIRONMENT a "staging" para probar
2. Olvida cambiarlo de vuelta a "local"
3. Ejecuta tests → escriben datos en S3 de staging
4. Otro developer lee esos datos como "reales" en staging
5. Pasa a producción con datos de test → corrupción

Otro escenario:
1. Lambda en producción tiene S3_BUCKET=ai-assets-staging
2. Typo al configurar → lee datos de staging, no de producción
3. Usuarios reciben respuestas con datos de prueba
4. Nadie entiende por qué — el código "está bien"

Config management previene estos escenarios con validación en el momento de la carga, no en runtime cuando ya es tarde.

Qué necesita un sistema de config para producción

RequisitoSin estructuraCon Pydantic Settings
TipadoTodo es stringint, bool, Optional[str]
ValidaciónNingunaValidators que rechazan valores inválidos
DefaultsHardcodeados en códigoCentralizados en la clase
SecretsEn .env (en git?)Separados, via IAM/env vars del sistema
DocumentaciónREADME manualLa clase ES la documentación
ComposiciónUn .env gigante.env por entorno

Pydantic Settings: Config Tipada

La clase de settings base

"""config/settings.py — Settings tipados con Pydantic."""

from pydantic_settings import BaseSettings
from pydantic import field_validator, model_validator
from typing import Optional
from enum import Enum


class EnvironmentName(str, Enum):
    LOCAL = "local"
    STAGING = "staging"
    PRODUCTION = "production"


class Settings(BaseSettings):
    """Configuración completa de la aplicación.

    Carga valores desde:
    1. Variables de entorno del sistema
    2. Archivo .env (si existe)
    3. Defaults definidos aquí
    """

    # --- Identidad del entorno ---
    environment: EnvironmentName = EnvironmentName.LOCAL
    app_name: str = "ai-migration-app"
    app_version: str = "1.0.0"
    debug: bool = False

    # --- AWS / Cloud ---
    aws_region: str = "us-east-1"
    aws_endpoint_url: Optional[str] = None
    aws_access_key_id: Optional[str] = None
    aws_secret_access_key: Optional[str] = None

    # --- S3 ---
    s3_bucket: str = "ai-assets-local"
    s3_prefix_prompts: str = "prompts/"
    s3_prefix_documents: str = "documents/"
    s3_prefix_responses: str = "responses/"

    # --- Lambda ---
    lambda_function_name: str = "ai-processor-local"
    lambda_timeout: int = 120
    lambda_memory: int = 768

    # --- AI / LLM ---
    openai_api_key: Optional[str] = None
    openai_model: str = "gpt-4o-mini"
    openai_max_tokens: int = 1000
    openai_temperature: float = 0.3

    # --- Feature Flags ---
    feature_sagemaker_enabled: bool = False
    feature_advanced_logging: bool = False
    feature_cost_tracking: bool = False

    # --- Operational ---
    log_level: str = "INFO"
    request_timeout: int = 30
    max_retries: int = 3
    circuit_breaker_threshold: int = 5

    @field_validator("environment", mode="before")
    @classmethod
    def normalize_environment(cls, v):
        if isinstance(v, str):
            return v.lower().strip()
        return v

    @field_validator("log_level", mode="before")
    @classmethod
    def normalize_log_level(cls, v):
        if isinstance(v, str):
            v = v.upper().strip()
            valid = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
            if v not in valid:
                raise ValueError(f"log_level debe ser uno de {valid}, recibido: '{v}'")
        return v

    @model_validator(mode="after")
    def validate_environment_config(self):
        """Valida que la config es coherente para el entorno."""
        if self.environment == EnvironmentName.LOCAL:
            if not self.aws_endpoint_url:
                self.aws_endpoint_url = "http://localhost:4566"
            if not self.aws_access_key_id:
                self.aws_access_key_id = "test"
                self.aws_secret_access_key = "test"

        if self.environment == EnvironmentName.PRODUCTION:
            if self.debug:
                raise ValueError(
                    "debug=True no es permitido en producción"
                )
            if self.aws_endpoint_url:
                raise ValueError(
                    "aws_endpoint_url no debe setearse en producción "
                    "(podría redirigir tráfico a LocalStack)"
                )

        return self

    @property
    def is_local(self) -> bool:
        return self.environment == EnvironmentName.LOCAL

    @property
    def is_aws(self) -> bool:
        return self.environment in (EnvironmentName.STAGING, EnvironmentName.PRODUCTION)

    @property
    def is_production(self) -> bool:
        return self.environment == EnvironmentName.PRODUCTION

    class Config:
        env_file = ".env"
        env_file_encoding = "utf-8"
        use_enum_values = True

Carga de settings con .env file específico

"""config/loader.py — Carga settings según el entorno."""

import os
from config.settings import Settings


def get_settings(env_name: str | None = None) -> Settings:
    """Carga settings desde el .env del entorno indicado.

    Prioridad:
    1. Variables de entorno del sistema (siempre ganan)
    2. Archivo .env.{environment}
    3. Defaults de la clase Settings
    """
    env = env_name or os.environ.get("ENVIRONMENT", "local")
    env_file = f".env.{env}"

    if os.path.exists(env_file):
        return Settings(_env_file=env_file)

    return Settings()


# Singleton para uso en la aplicación
_settings: Settings | None = None


def get_cached_settings() -> Settings:
    """Retorna settings cacheados (singleton)."""
    global _settings
    if _settings is None:
        _settings = get_settings()
    return _settings

Archivos .env por Entorno

.env.local — Desarrollo con LocalStack

# .env.local — Desarrollo local con LocalStack
ENVIRONMENT=local
APP_NAME=ai-migration-app
DEBUG=true

# AWS / LocalStack
AWS_REGION=us-east-1
AWS_ENDPOINT_URL=http://localhost:4566
AWS_ACCESS_KEY_ID=test
AWS_SECRET_ACCESS_KEY=test

# S3
S3_BUCKET=ai-assets-local

# Lambda
LAMBDA_FUNCTION_NAME=ai-processor-local
LAMBDA_TIMEOUT=120
LAMBDA_MEMORY=768

# AI (usa tu key real para invocar OpenAI incluso en local)
OPENAI_API_KEY=sk-your-dev-key-here
OPENAI_MODEL=gpt-4o-mini
OPENAI_MAX_TOKENS=500
OPENAI_TEMPERATURE=0.3

# Feature flags
FEATURE_SAGEMAKER_ENABLED=false
FEATURE_ADVANCED_LOGGING=false
FEATURE_COST_TRACKING=false

# Operational
LOG_LEVEL=DEBUG
REQUEST_TIMEOUT=30
MAX_RETRIES=3
CIRCUIT_BREAKER_THRESHOLD=10

.env.staging — AWS Staging

# .env.staging — AWS Staging
ENVIRONMENT=staging
APP_NAME=ai-migration-app
DEBUG=true

# AWS (sin endpoint_url → usa AWS real)
AWS_REGION=us-east-1
# Credenciales via IAM role — NO poner keys aquí

# S3
S3_BUCKET=ai-assets-staging-123456789012

# Lambda
LAMBDA_FUNCTION_NAME=ai-processor-staging
LAMBDA_TIMEOUT=120
LAMBDA_MEMORY=768

# AI
OPENAI_MODEL=gpt-4o-mini
OPENAI_MAX_TOKENS=800
OPENAI_TEMPERATURE=0.3
# OPENAI_API_KEY via secrets manager o env var del sistema

# Feature flags
FEATURE_SAGEMAKER_ENABLED=true
FEATURE_ADVANCED_LOGGING=true
FEATURE_COST_TRACKING=true

# Operational
LOG_LEVEL=INFO
REQUEST_TIMEOUT=30
MAX_RETRIES=3
CIRCUIT_BREAKER_THRESHOLD=5

.env.production — AWS Producción

# .env.production — AWS Producción
ENVIRONMENT=production
APP_NAME=ai-migration-app
DEBUG=false

# AWS
AWS_REGION=us-east-1
# Credenciales via IAM role exclusivamente

# S3
S3_BUCKET=ai-assets-prod-123456789012

# Lambda
LAMBDA_FUNCTION_NAME=ai-processor-prod
LAMBDA_TIMEOUT=60
LAMBDA_MEMORY=1024

# AI
OPENAI_MODEL=gpt-4o-mini
OPENAI_MAX_TOKENS=1000
OPENAI_TEMPERATURE=0.2
# OPENAI_API_KEY via AWS Secrets Manager

# Feature flags
FEATURE_SAGEMAKER_ENABLED=true
FEATURE_ADVANCED_LOGGING=true
FEATURE_COST_TRACKING=true

# Operational
LOG_LEVEL=WARNING
REQUEST_TIMEOUT=15
MAX_RETRIES=5
CIRCUIT_BREAKER_THRESHOLD=3

.gitignore — Nunca commitear secrets

# .gitignore
.env
.env.local
.env.staging
.env.production

# Pero SÍ commitea los templates:
# .env.example → template sin valores reales

.env.example — Template para nuevos developers

# .env.example — Copia como .env.{entorno} y llena los valores
ENVIRONMENT=local
AWS_REGION=us-east-1
AWS_ENDPOINT_URL=http://localhost:4566
AWS_ACCESS_KEY_ID=test
AWS_SECRET_ACCESS_KEY=test
S3_BUCKET=ai-assets-local
OPENAI_API_KEY=sk-your-key-here
LOG_LEVEL=DEBUG

Validación de Config por Entorno

Validador que previene errores costosos

"""config/validators.py — Validaciones adicionales de configuración."""

from config.settings import Settings, EnvironmentName


class ConfigValidationError(Exception):
    """Error de validación de configuración."""
    pass


def validate_config(settings: Settings) -> list[str]:
    """Valida la configuración y retorna warnings.

    Raises ConfigValidationError para problemas críticos.
    """
    warnings = []

    # Producción: debe tener secrets via IAM, no en .env
    if settings.is_production:
        if settings.aws_access_key_id and settings.aws_access_key_id != "test":
            raise ConfigValidationError(
                "Producción no debe tener AWS_ACCESS_KEY_ID en config. "
                "Usa IAM roles."
            )

    # Staging: verificar que no apunte a LocalStack
    if settings.environment == EnvironmentName.STAGING:
        if settings.aws_endpoint_url and "localhost" in settings.aws_endpoint_url:
            raise ConfigValidationError(
                "Staging apunta a localhost — ¿es esto intencional? "
                "Staging debería usar AWS real."
            )

    # OpenAI key: warning si no está en entornos que lo necesitan
    if not settings.openai_api_key and settings.is_aws:
        warnings.append(
            "OPENAI_API_KEY no configurado. La inferencia AI fallará."
        )

    # Feature flags: warning si SageMaker habilitado en local
    if settings.feature_sagemaker_enabled and settings.is_local:
        warnings.append(
            "FEATURE_SAGEMAKER_ENABLED=true en local. "
            "LocalStack tiene soporte limitado de SageMaker."
        )

    # Timeout: warning si muy bajo en producción
    if settings.is_production and settings.request_timeout < 10:
        warnings.append(
            f"REQUEST_TIMEOUT={settings.request_timeout}s es bajo para producción. "
            f"Considera 15-30s."
        )

    # Lambda memory: warning si baja para AI workloads
    if settings.lambda_memory < 512:
        warnings.append(
            f"LAMBDA_MEMORY={settings.lambda_memory}MB puede ser insuficiente "
            f"para workloads AI. Recomendado: 768MB+."
        )

    return warnings


def validate_and_report(settings: Settings):
    """Valida config e imprime reporte."""
    print(f"Validando configuración para: {settings.environment}")

    try:
        warnings = validate_config(settings)
    except ConfigValidationError as e:
        print(f"❌ ERROR CRÍTICO: {e}")
        raise

    if warnings:
        for w in warnings:
            print(f"⚠️  {w}")
    else:
        print("✅ Configuración válida sin warnings")

Startup validation — fallar rápido

"""app_startup.py — Validación al iniciar la aplicación."""

import sys
from config.loader import get_settings
from config.validators import validate_and_report, ConfigValidationError


def startup():
    """Carga y valida configuración al iniciar."""
    try:
        settings = get_settings()
    except Exception as e:
        print(f"❌ Error cargando configuración: {e}")
        sys.exit(1)

    try:
        validate_and_report(settings)
    except ConfigValidationError as e:
        print(f"❌ Configuración inválida. No se puede iniciar.")
        sys.exit(1)

    print(f"\n{'='*50}")
    print(f"App: {settings.app_name} v{settings.app_version}")
    print(f"Entorno: {settings.environment}")
    print(f"Region: {settings.aws_region}")
    print(f"Bucket: {settings.s3_bucket}")
    print(f"Debug: {settings.debug}")
    print(f"{'='*50}\n")

    return settings


if __name__ == "__main__":
    startup()

Secrets Management

El problema: secretos en archivos de configuración

La regla de oro: si un valor comprometido causa daño, es un secret.

Secrets:
├── AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY → acceso a tu cuenta AWS
├── OPENAI_API_KEY → consumo de tu crédito de OpenAI
└── DATABASE_PASSWORD → acceso a tus datos

NO son secrets:
├── AWS_REGION → es público (us-east-1)
├── S3_BUCKET → el nombre no da acceso (IAM sí)
├── LOG_LEVEL → no hay daño si se expone
└── LAMBDA_TIMEOUT → configuración operacional

Patrón para secrets por entorno

"""config/secrets.py — Gestión de secrets por entorno."""

import os
import json
from config.settings import Settings


class SecretsProvider:
    """Provee secrets según el entorno."""

    def __init__(self, settings: Settings):
        self.settings = settings

    def get_openai_key(self) -> str:
        """Obtiene la API key de OpenAI."""
        # Prioridad 1: variable de entorno directa
        key = os.environ.get("OPENAI_API_KEY")
        if key:
            return key

        # Prioridad 2: en AWS, usar Secrets Manager
        if self.settings.is_aws:
            return self._get_from_secrets_manager("openai-api-key")

        # Prioridad 3: leer de .env (ya cargado en settings)
        if self.settings.openai_api_key:
            return self.settings.openai_api_key

        raise ValueError("OPENAI_API_KEY no configurado en ninguna fuente")

    def _get_from_secrets_manager(self, secret_name: str) -> str:
        """Lee un secret de AWS Secrets Manager."""
        import boto3

        kwargs = {"region_name": self.settings.aws_region}
        if self.settings.aws_endpoint_url:
            kwargs["endpoint_url"] = self.settings.aws_endpoint_url

        client = boto3.client("secretsmanager", **kwargs)
        response = client.get_secret_value(SecretId=secret_name)

        secret_string = response["SecretString"]
        try:
            secret_dict = json.loads(secret_string)
            return secret_dict.get("api_key", secret_string)
        except json.JSONDecodeError:
            return secret_string

Crear un secret en LocalStack (para testing)

import boto3

sm = boto3.client(
    "secretsmanager",
    endpoint_url="http://localhost:4566",
    aws_access_key_id="test",
    aws_secret_access_key="test",
    region_name="us-east-1",
)

sm.create_secret(
    Name="openai-api-key",
    SecretString=json.dumps({"api_key": "sk-test-key-for-localstack"}),
)

response = sm.get_secret_value(SecretId="openai-api-key")
print(f"Secret: {response['SecretString']}")

Config Comparison Tool

Herramienta para comparar configs entre entornos

"""config/compare.py — Compara configuración entre entornos."""

from config.settings import Settings, EnvironmentName


def compare_configs(env_a: str, env_b: str) -> dict:
    """Compara la configuración de dos entornos."""
    settings_a = Settings(environment=env_a, _env_file=f".env.{env_a}")
    settings_b = Settings(environment=env_b, _env_file=f".env.{env_b}")

    fields_a = settings_a.model_dump()
    fields_b = settings_b.model_dump()

    differences = {}
    for key in fields_a:
        val_a = fields_a[key]
        val_b = fields_b[key]
        if val_a != val_b:
            differences[key] = {"env_a": val_a, "env_b": val_b}

    return differences


def print_config_diff(env_a: str = "local", env_b: str = "staging"):
    """Imprime las diferencias de configuración entre dos entornos."""
    diffs = compare_configs(env_a, env_b)

    print(f"\n{'='*60}")
    print(f"CONFIG DIFF: {env_a} vs {env_b}")
    print(f"{'='*60}")

    if not diffs:
        print("No hay diferencias (¿mismo .env?)")
        return

    # Separar secrets de config normal
    secret_keys = {"aws_access_key_id", "aws_secret_access_key", "openai_api_key"}

    for key, vals in sorted(diffs.items()):
        if key in secret_keys:
            print(f"\n  🔒 {key}:")
            print(f"     {env_a}: {'*****' if vals['env_a'] else 'Not set'}")
            print(f"     {env_b}: {'*****' if vals['env_b'] else 'Not set'}")
        else:
            print(f"\n  📋 {key}:")
            print(f"     {env_a}: {vals['env_a']}")
            print(f"     {env_b}: {vals['env_b']}")

    print(f"\n  Total diferencias: {len(diffs)}")
    print(f"{'='*60}")

Ejemplo de output:

============================================================
CONFIG DIFF: local vs staging
============================================================

  📋 environment:
     local: local
     staging: staging

  📋 debug:
     local: True
     staging: True

  📋 aws_endpoint_url:
     local: http://localhost:4566
     staging: None

  🔒 aws_access_key_id:
     local: *****
     staging: Not set

  📋 s3_bucket:
     local: ai-assets-local
     staging: ai-assets-staging-123456789012

  📋 feature_sagemaker_enabled:
     local: False
     staging: True

  📋 log_level:
     local: DEBUG
     staging: INFO

  Total diferencias: 12
============================================================

Troubleshooting

Problema 1: Pydantic ValidationError al iniciar

Un valor en .env no coincide con el tipo esperado.

# .env tiene:
# LAMBDA_TIMEOUT=abc  ← debería ser int

# Error:
# ValidationError: 1 validation error for Settings
# lambda_timeout
#   Input should be a valid integer [type=int_parsing, ...]

# Solución: verificar tipos en .env
# LAMBDA_TIMEOUT=120  ← correcto

Problema 2: Settings ignora el archivo .env

Pydantic Settings prioriza variables de entorno del sistema sobre el .env.

# Si tienes esto en el shell:
export ENVIRONMENT=production

# Y esto en .env.local:
ENVIRONMENT=local

# Pydantic usa "production" (env var del sistema gana)

# Solución: unset la variable del sistema
unset ENVIRONMENT
# O sé explícito en el loader:
# Settings(_env_file=".env.local")

Problema 3: Config de producción tiene debug=True

El validator validate_environment_config debe prevenirlo, pero si se bypasea:

# La clase Settings tiene:
# @model_validator(mode="after")
# def validate_environment_config(self):
#     if self.environment == "production" and self.debug:
#         raise ValueError("debug=True no es permitido en producción")

# Esto previene iniciar con config inválida.
# Si el error persiste, verifica que el .env.production no tenga DEBUG=true.

Problema 4: Secrets de staging accesibles en local

Esto pasa cuando el developer tiene credenciales AWS en ~/.aws/credentials y no setea AWS_ENDPOINT_URL:

# El client sin endpoint_url → habla con AWS real
# Incluso si ENVIRONMENT=local

# Solución: el validator de Settings setea endpoint_url automáticamente en local:
# if self.environment == "local" and not self.aws_endpoint_url:
#     self.aws_endpoint_url = "http://localhost:4566"

Problema 5: .env.example está desactualizado

Agregar un script que verifique que .env.example tiene las mismas keys que la clase Settings:

from config.settings import Settings

settings_keys = set(Settings.model_fields.keys())

with open(".env.example") as f:
    example_keys = set()
    for line in f:
        line = line.strip()
        if line and not line.startswith("#") and "=" in line:
            key = line.split("=")[0].strip().lower()
            example_keys.add(key)

missing = settings_keys - example_keys
extra = example_keys - settings_keys

if missing:
    print(f"⚠️ Keys en Settings pero no en .env.example: {missing}")
if extra:
    print(f"⚠️ Keys en .env.example pero no en Settings: {extra}")
if not missing and not extra:
    print("✅ .env.example sincronizado con Settings")

Ejercicios Prácticos

Ejercicio 1: Settings con validación custom

Extiende la clase Settings con un campo max_document_size_mb (int, default 10) y un validator que rechace valores mayores a 50 en producción y mayores a 100 en otros entornos.

Ver solución
from pydantic_settings import BaseSettings
from pydantic import field_validator, model_validator
from typing import Optional
from enum import Enum


class EnvironmentName(str, Enum):
    LOCAL = "local"
    STAGING = "staging"
    PRODUCTION = "production"


class Settings(BaseSettings):
    environment: EnvironmentName = EnvironmentName.LOCAL
    aws_region: str = "us-east-1"
    aws_endpoint_url: Optional[str] = None
    s3_bucket: str = "ai-assets-local"
    max_document_size_mb: int = 10

    @model_validator(mode="after")
    def validate_document_size(self):
        if self.environment == EnvironmentName.PRODUCTION:
            if self.max_document_size_mb > 50:
                raise ValueError(
                    f"max_document_size_mb={self.max_document_size_mb} "
                    f"excede el límite de 50MB para producción"
                )
        else:
            if self.max_document_size_mb > 100:
                raise ValueError(
                    f"max_document_size_mb={self.max_document_size_mb} "
                    f"excede el límite de 100MB"
                )
        return self

    class Config:
        env_file = ".env"
        use_enum_values = True


# Test: producción con 60MB → falla
try:
    s = Settings(environment="production", max_document_size_mb=60)
except ValueError as e:
    print(f"✅ Validación correcta: {e}")

# Test: local con 80MB → OK
s = Settings(environment="local", max_document_size_mb=80)
print(f"✅ Local con 80MB: OK")

# Test: local con 150MB → falla
try:
    s = Settings(environment="local", max_document_size_mb=150)
except ValueError as e:
    print(f"✅ Validación correcta: {e}")

Ejercicio 2: Config loader con fallback chain

Implementa un config loader que intente cargar en este orden: .env.{env}.local (overrides locales) → .env.{env}.env → defaults. El primer archivo que exista se usa.

Ver solución
import os
from pydantic_settings import BaseSettings
from typing import Optional


class Settings(BaseSettings):
    environment: str = "local"
    aws_region: str = "us-east-1"
    aws_endpoint_url: Optional[str] = None
    s3_bucket: str = "ai-assets-local"
    debug: bool = False

    class Config:
        env_file = ".env"
        use_enum_values = True


def load_settings_with_fallback(env_name: str | None = None) -> Settings:
    """Carga settings con cadena de fallback de archivos .env."""
    env = env_name or os.environ.get("ENVIRONMENT", "local")

    candidates = [
        f".env.{env}.local",  # Overrides locales del developer
        f".env.{env}",        # Config del entorno
        ".env",               # Fallback general
    ]

    env_file_used = None
    for candidate in candidates:
        if os.path.exists(candidate):
            env_file_used = candidate
            break

    if env_file_used:
        print(f"📁 Config cargada desde: {env_file_used}")
        return Settings(_env_file=env_file_used)
    else:
        print(f"📁 Config cargada desde: defaults (ningún .env encontrado)")
        return Settings()


settings = load_settings_with_fallback()
print(f"Entorno: {settings.environment}")
print(f"Bucket: {settings.s3_bucket}")
print(f"Debug: {settings.debug}")

Ejercicio 3: Config export/import

Crea funciones para exportar la configuración actual (sin secrets) a un archivo JSON, e importar configuración desde un JSON para comparar o restaurar.

Ver solución
import json
from datetime import datetime
from config.settings import Settings


SECRET_FIELDS = {"aws_access_key_id", "aws_secret_access_key", "openai_api_key"}


def export_config(settings: Settings, filepath: str) -> str:
    """Exporta configuración actual a JSON (sin secrets)."""
    config_dict = settings.model_dump()

    for field in SECRET_FIELDS:
        if field in config_dict and config_dict[field]:
            config_dict[field] = "***REDACTED***"

    export = {
        "exported_at": datetime.utcnow().isoformat(),
        "environment": settings.environment,
        "config": config_dict,
    }

    with open(filepath, "w") as f:
        json.dump(export, f, indent=2, default=str)

    print(f"✅ Config exportada a: {filepath}")
    print(f"   Entorno: {settings.environment}")
    print(f"   Campos: {len(config_dict)}")
    print(f"   Secrets redactados: {len(SECRET_FIELDS)}")
    return filepath


def import_and_compare(filepath: str, current: Settings) -> dict:
    """Importa config de JSON y compara con la actual."""
    with open(filepath) as f:
        imported = json.load(f)

    imported_config = imported["config"]
    current_config = current.model_dump()

    differences = {}
    for key in current_config:
        imported_val = imported_config.get(key)
        current_val = current_config[key]

        if key in SECRET_FIELDS:
            continue

        if imported_val != current_val:
            differences[key] = {
                "imported": imported_val,
                "current": current_val,
            }

    print(f"\n📊 Comparación: {filepath} vs config actual")
    print(f"   Importado de: {imported.get('exported_at', 'unknown')}")
    if differences:
        for key, vals in differences.items():
            print(f"   ≠ {key}: {vals['imported']}{vals['current']}")
    else:
        print("   ✅ Sin diferencias (excluyendo secrets)")

    return differences


settings = Settings()
export_config(settings, "config-backup.json")
diffs = import_and_compare("config-backup.json", settings)

Ejercicio 4: Environment-aware logging config

Crea una función que configure el logging de Python según el entorno: DEBUG con formato detallado en local, INFO con formato JSON en staging, WARNING con formato JSON compacto en producción.

Ver solución
import logging
import json
from datetime import datetime
from config.settings import Settings, EnvironmentName


class JSONFormatter(logging.Formatter):
    """Formatter que produce JSON para entornos cloud."""

    def __init__(self, compact: bool = False):
        super().__init__()
        self.compact = compact

    def format(self, record):
        log_data = {
            "timestamp": datetime.utcnow().isoformat(),
            "level": record.levelname,
            "message": record.getMessage(),
            "module": record.module,
        }
        if not self.compact:
            log_data["function"] = record.funcName
            log_data["line"] = record.lineno
        if record.exc_info and record.exc_info[0]:
            log_data["exception"] = self.formatException(record.exc_info)
        return json.dumps(log_data)


def configure_logging(settings: Settings) -> logging.Logger:
    """Configura logging según el entorno."""
    logger = logging.getLogger(settings.app_name)
    logger.handlers.clear()
    logger.setLevel(getattr(logging, settings.log_level))

    handler = logging.StreamHandler()

    if settings.is_local:
        formatter = logging.Formatter(
            "%(asctime)s [%(levelname)s] %(name)s.%(funcName)s:%(lineno)d — %(message)s",
            datefmt="%H:%M:%S",
        )
    elif settings.is_production:
        formatter = JSONFormatter(compact=True)
    else:
        formatter = JSONFormatter(compact=False)

    handler.setFormatter(formatter)
    logger.addHandler(handler)

    logger.info(f"Logging configurado: level={settings.log_level}, "
                f"env={settings.environment}")
    return logger


# Demo
for env in ["local", "staging", "production"]:
    print(f"\n--- Entorno: {env} ---")
    try:
        s = Settings(
            environment=env,
            log_level="DEBUG" if env == "local" else "INFO" if env == "staging" else "WARNING",
            debug=(env == "local"),
        )
        logger = configure_logging(s)
        logger.debug("Mensaje debug")
        logger.info("Mensaje info")
        logger.warning("Mensaje warning")
    except Exception as e:
        print(f"  Config error: {e}")

Resumen

  • Config management no es "pon un .env." Es tipado con Pydantic, validación por entorno, separación de secrets, y archivos .env específicos por entorno.
  • Pydantic Settings es tu fuente de verdad. La clase define qué config existe, qué tipo tiene, cuál es el default, y qué validaciones aplican. La clase ES la documentación.
  • Tres archivos .env: .env.local (LocalStack), .env.staging (AWS staging), .env.production (AWS prod). Nunca en git. Sí un .env.example como template.
  • Validators que protegen: debug=True en producción → error. endpoint_url en producción → error. SageMaker enabled en local → warning. Fallar rápido es mejor que fallar en runtime.
  • Secrets van separados. En local, en .env (fuera de git). En AWS, via IAM roles o Secrets Manager. Nunca hardcodeados en código.
  • En la siguiente cápsula, usarás esta configuración para inyectar boto3 clients con dependency injection.

Recursos Adicionales

  1. Pydantic Settings Management — Documentación oficial
  2. The Twelve-Factor App — Config — Principios de configuración
  3. python-dotenv — Carga de archivos .env
  4. AWS Secrets Manager — Gestión de secrets en AWS
  5. Pydantic Validators — Validación custom en Pydantic
  6. OWASP — Secrets Management — Best practices de secrets