Módulo 5 · Arquitectura y código mantenible

Lección 27 — Configuración por entorno (12-factor)

Secretos y settings por entorno: la misma imagen, tres despliegues.

Publicada
En esta lección
  1. Ejercicio 1 — Auditoría
  2. Ejercicio 2 — settings.py completo
  3. Ejercicio 3 — Rotación dual
  4. Ejercicio 4 — Fail-fast
  5. Ejercicio 5 — Feature flags por config
  6. Resumen del profesor

Ejercicio 1 — Auditoría

Hallazgos típicos de un proyecto Django al llegar aquí: ALLOWED_HOSTS = ["localhost"] clavado, EMAIL_HOST literal en settings, la URL de la pasarela de pagos dentro del servicio (requests.post("https://api.pay-gw.com/charges",...)) y el webhook secret en un fixture comprometido el mes pasado. Clasificación: URLs de pasarela y SMTP = bad config (se mueven al entorno); DEBUG=True global = bad config peligrosa; los if settings.DEBUG: en vistas = config que filtra a la capa de dominio (el servicio NO debe leer settings: recibe valores por parámetro — la inversión de la 24).

El grep final:

bash
git grep -n "https://" -- '*.py' | grep -v test | grep -v "# doc"
# esperado: solo docstrings y el DTO que documentan la API

La regla preventiva: una URL que no es de tu API es config; si la necesitas en un servicio, entra por parámetro (inyección de la 24) y su valor viene de settings.

Ejercicio 2 — settings.py completo

python
# settings.py
import environ, os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
env = environ.Env(
    DJANGO_DEBUG=(bool, False),
    DJANGO_SECRET_KEY=(str, ""),
    ALLOWED_HOSTS=(list, []),
    DATABASE_URL=(str, ""),
    REDIS_URL=(str, ""),
    CELERY_BROKER_URL=(str, ""),
    PAYMENT_GATEWAY_URL=(str, "https://sandbox.gw.example"),
    PAYMENT_GATEWAY_KEY=(str, ""),
    WEBHOOK_SECRET=(str, ""),
    FEATURE_WEBHOOKS=(bool, False),
)
environ.Env.read_env(BASE_DIR / ".env")   # dev: no-op en prod (no existe el fichero)

SECRET_KEY = env("DJANGO_SECRET_KEY")
DEBUG = env("DJANGO_DEBUG")
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS")
DATABASES = {"default": env.db("DATABASE_URL") if env("DATABASE_URL") else {
    "ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db.sqlite3"  # solo CI sin servicio
}}
CACHES = {"default": env.cache("REDIS_URL")} if env("REDIS_URL") else {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}

.env.example:

bash
DJANGO_DEBUG=false
DJANGO_SECRET_KEY=  # genera con: python -c "import secrets; print(secrets.token_urlsafe(64))"
ALLOWED_HOSTS=localhost,127.0.0.1
DATABASE_URL=postgres://user:pass@localhost:5432/ticketflow
REDIS_URL=redis://localhost:6379/0
PAYMENT_GATEWAY_URL=https://sandbox.gw.example
PAYMENT_GATEWAY_KEY=
WEBHOOK_SECRET=
FEATURE_WEBHOOKS=false

El .env real se ignora (git check-ignore.env confirma) y los secretos de staging/prod viven en el gestor del orquestador (42/43), no en ficheros. La prueba de la misma imagen: un solo docker build → el digest sha256:... idéntico en los 3 docker run con distinto --env-file. Si el hash cambia, el Dockerfile filtra config.

Ejercicio 3 — Rotación dual

python
SECRET_KEYS = [k for k in (env("DJANGO_SECRET_KEY"), env("DJANGO_SECRET_KEY_OLD")) if k]

# en la verificación de firma (HMAC de la 17 o tokens):
def verify(data: bytes, signature: str) -> bool:
    return any(hmac.compare_digest(compute(data, k), signature) for k in SECRET_KEYS)

Checklist de rotación (la ventana entre 2 y 4 = la vida máxima de lo firmado con la clave vieja):

  1. Generar clave nueva, NO deployar aún.
  2. Deploy con DJANGO_SECRET_KEY=nueva + DJANGO_SECRET_KEY_OLD=vieja (dual).
  3. Observar métricas de firma fallida (46): debe ser ~0 tras el deploy.
  4. Esperar ≥ 2× el TTL máximo de lo firmado (access token 10 min de la 18 → 20 min basta).
  5. Deploy retirando *_OLD. Si algo firmó con la vieja después del paso 4, era tráfico no gestionado: investigar, no rotar.

Los tokens emitidos con la clave vieja siguen válidos durante la ventana (por diseño); después mueren — por eso el access token es corto (18) y el refresh lo re-emite con la nueva.

Ejercicio 4 — Fail-fast

python
if not DEBUG:
    if not SECRET_KEY:
        raise ImproperlyConfigured(
            "DJANGO_SECRET_KEY no está definida. Genera una con "
            "`python -c \"import secrets; print(secrets.token_urlsafe(64))\"` y ponla en el .env."
        )
    if not ALLOWED_HOSTS:
        raise ImproperlyConfigured(
            "ALLOWED_HOSTS vacío fuera de DEBUG: Django rechazaría todas las peticiones "
            "(header Host). Añade tu dominio, separado por comas."
        )

El test de humo con override del entorno:

python
class SmokeSettings(TestCase):
    def test_prod_fake_falla_sin_secret(self):
        with mock.patch.dict(os.environ, {"DJANGO_DEBUG": "false", "DJANGO_SECRET_KEY": ""}):
            with self.assertRaises(ImproperlyConfigured):
                importlib.reload(settings_module)

    def test_prod_fake_con_config_completa_pasa_check_deploy(self):
        ...vars seteadas...
        call_command("check", "--deploy")

El README del triage: las 3 que faltan siempre son DJANGO_SECRET_KEY (500 al arrancar con mensaje exacto), DATABASE_URL (el error de conexión llega en el primer request) y REDIS_URL (caché silenciosamente inexistente: el sistema funciona LENTO, no roto — el peor fallo). Cada una con su síntoma y su fix en dos líneas.

Ejercicio 5 — Feature flags por config

python
# settings.py
FEATURE_WEBHOOKS = env.bool("FEATURE_WEBHOOKS", default=False)
FEATURE_SAGA_V2 = env.bool("FEATURE_SAGA_V2", default=False)

# servicio:
if settings.FEATURE_WEBHOOKS:
    outbox.publish(ReservationConfirmed(...))

El test corre la misma suite con flag on y off (override_settings(FEATURE_WEBHOOKS=True/False)): ambas rutas viven. Frente a branches por entorno: los branches divergen y el merge es la guerra; los flags conviven en trunk y se activan por despliegue. La 41 los usará para desacoplar deploy de release.


Resumen del profesor

  • La config vive en el entorno; defaults seguros en código; la imagen es una y los entornos son variables.
  • Un settings.py plano sin branches: cada clave leída una vez, tipos parseados, fallos legibles.
  • Fail-fast al arrancar + check --deploy en pipeline: la config rota se detecta en minutos, no en la queja del cliente.