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:
git grep -n "https://" -- '*.py' | grep -v test | grep -v "# doc"
# esperado: solo docstrings y el DTO que documentan la APILa 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
# 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:
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=falseEl .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
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):
- Generar clave nueva, NO deployar aún.
- Deploy con
DJANGO_SECRET_KEY=nueva+DJANGO_SECRET_KEY_OLD=vieja(dual). - Observar métricas de firma fallida (46): debe ser ~0 tras el deploy.
- Esperar ≥ 2× el TTL máximo de lo firmado (access token 10 min de la 18 → 20 min basta).
- 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
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:
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
# 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.