Cuaderno/Proyectos/6 · Auth, secretos y deploy
Proyecto 6 de 10 · Bloque 2

Auth, secretos y deploy

La API sale de tu laptop y queda accesible en internet, con llave. Mismo código, distinta configuración: eso es un entorno. Y en prod no se prueba: se observa.

concepto · entornos, secretos y "funciona en prod"4 sesionesapi keys · pydantic-settings · postgres · cloud run · logs json
ConstruyesLa API del inventario autenticada y desplegada en Cloud Run
AprendesConfig por entorno, secretos fuera del repo, cambiar de motor de DB, leer logs de prod
CostoCentavos o cero: free tier de Cloud Run y Neon, con presupuesto con alertas
Terminas conRodrigo llamando a tu API desde su casa con la llave que le diste

Objetivos

  • Sacar toda la configuración del código: variables de entorno con pydantic-settings, .env local ignorado, .env.example commiteado.
  • Proteger la API con API keys: se generan una vez, se guarda su hash, se pueden revocar. 401 para todo lo demás.
  • Cambiar SQLite por Postgres cambiando una sola variable, y entender por qué eso es señal de buen diseño.
  • Emitir logs estructurados (JSON) y usarlos para encontrar un error sin abrir el código.
  • Desplegar a Cloud Run con la base en Neon, y dejar el deploy automático desde main cuando los tests pasan.
El cambio de mentalidad

Hasta el P5 todo corría donde tú estabas mirando. Desde hoy tu código corre en una máquina que no ves, con una configuración que no es la tuya, atendiendo a gente que no eres tú. Las tres herramientas de este proyecto —config por entorno, llaves, logs— existen porque ya no puedes mirar.

Temas que vas a usar

De El libro de Python y de esta guía:

Antes de empezar

Necesitas el repo inventario-lab como quedó en el P5 (API + cliente + tablero, sobre los services del P3) y además dos cuentas nuevas. Igual que en los tutoriales de GCP: primero se prepara todo, después se construye.

1. Cuenta de Google Cloud y gcloud CLI

Crea una cuenta en cloud.google.com (los usuarios nuevos reciben crédito gratis, y Cloud Run tiene free tier permanente). Instala la gcloud CLI y prepara el proyecto:

gcloud init                          # inicia sesión y crea/elige el proyecto
gcloud config set project inventario-alexandra
gcloud services enable run.googleapis.com cloudbuild.googleapis.com \
    artifactregistry.googleapis.com secretmanager.googleapis.com
Ponle techo al gasto antes que nada

En la consola: Facturación → Presupuestos y alertas → Crear presupuesto, monto 5 USD, alertas a 50/90/100%. Este tutorial cabe en el free tier, pero ponerle techo a una nube es un hábito del día 0, como los tests.

2. Cuenta en Neon (Postgres gestionado, gratis)

Crea una cuenta en neon.tech (plan Free). Todavía no crees nada: lo hacemos en el paso 6. La alternativa de Google es Cloud SQL —lo que usarías en un trabajo con presupuesto—, pero su instancia más chica cuesta ~10 USD/mes; para aprender elegimos Neon. Anota esa decisión en DECISIONES.md.

3. Estructura de partida

inventario-lab/
├── src/inventario/
│   ├── models.py  db.py  services.py  errors.py  cli.py     (P3)
│   ├── api/  main.py · routers/ · schemas.py · deps.py       (P4)
│   └── client.py                                             (P5)
├── web/app.py                                                (P5)
├── tests/  migrations/  compose.yaml  Dockerfile  …

Construcción paso a paso

Paso 1 Toda la configuración sale del código

Hoy tu db.py tiene la URL de la base escrita adentro. Eso muere aquí. Crea la rama feat/settings y un módulo de configuración:

git switch -c feat/settings
uv add pydantic-settings "psycopg[binary]"
src/inventario/settings.py
"""Configuración del proyecto. Todo viene del entorno; nada está hardcodeado.

Variables (prefijo INVENTARIO_):
  INVENTARIO_DB_URL      sqlite:///inventario.db | postgresql+psycopg://user:pass@host:5432/db
  INVENTARIO_ENV         dev | prod
  INVENTARIO_LOG_LEVEL   DEBUG | INFO | WARNING
  INVENTARIO_VERSION     sha del commit desplegado (lo pone el deploy)
"""

from functools import lru_cache

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="INVENTARIO_", env_file=".env", extra="ignore")

    db_url: str = "sqlite:///inventario.db"
    env: str = "dev"
    log_level: str = "INFO"
    version: str = "dev"


@lru_cache
def get_settings() -> Settings:
    """Una sola instancia por proceso. En tests se limpia con get_settings.cache_clear()."""
    return Settings()

Ajusta db.py para leer de ahí (y de paso queda listo para Postgres):

src/inventario/db.py (fragmento)
from inventario.settings import get_settings


@lru_cache
def get_engine() -> Engine:
    url = get_settings().db_url
    connect_args = {"check_same_thread": False} if url.startswith("sqlite") else {}
    return create_engine(url, connect_args=connect_args)

Y los dos archivos de entorno. El ejemplo se commitea; el real, jamás:

.env.example
# Copia este archivo a .env y completa los valores. .env NUNCA se commitea.
INVENTARIO_DB_URL=sqlite:///inventario.db
INVENTARIO_ENV=dev
INVENTARIO_LOG_LEVEL=INFO
grep -n "^\.env$" .gitignore   # está desde el P0; si no aparece, agrégalo YA
cp .env.example .env
uv run pytest -q               # nada debió romperse
git add . && git commit -m "Config por entorno con pydantic-settings"
Dev y prod son el mismo código. Lo único que cambia entre tu laptop y Cloud Run son estas variables. Si mañana el código pregunta if env == "prod" para comportarse distinto, sospecha: casi siempre es configuración disfrazada de lógica.

Paso 2 Logs en JSON: una línea, un evento

En prod nadie lee print. Cloud Run recoge lo que escribas a stdout, y si cada línea es un JSON, lo indexa: puedes filtrar por severidad, por campo, por texto. Escribe el formateador:

src/inventario/logs.py
"""Logs en JSON, una línea por evento. Cloud Run los indexa solo (severity, message, campos)."""

import json
import logging
import sys
from datetime import UTC, datetime


class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        evento = {
            "time": datetime.now(UTC).isoformat(),
            "severity": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
        }
        # Campos extra pasados como logger.info("...", extra={"campos": {...}})
        campos = getattr(record, "campos", None)
        if campos:
            evento.update(campos)
        if record.exc_info:
            evento["exception"] = self.formatException(record.exc_info)
        return json.dumps(evento, ensure_ascii=False)


def configurar_logs(nivel: str = "INFO") -> None:
    handler = logging.StreamHandler(sys.stdout)
    handler.setFormatter(JsonFormatter())
    root = logging.getLogger()
    root.handlers = [handler]
    root.setLevel(nivel.upper())
    # uvicorn trae sus propios handlers; los alineamos para que todo salga en JSON
    for nombre in ("uvicorn", "uvicorn.error", "uvicorn.access"):
        logging.getLogger(nombre).handlers = []
        logging.getLogger(nombre).propagate = True

Llámalo al arrancar la app (configurar_logs(get_settings().log_level) en main.py) y úsalo en un router para ver un evento con campos:

src/inventario/api/routers/reactivos.py (fragmento)
log = logging.getLogger("inventario.api")


@router.get("")
def listar(q: str = "", session: Session = Depends(get_db)) -> list[dict]:
    reactivos = services.buscar_reactivo(session, q)
    log.info("buscar reactivos", extra={"campos": {"q": q, "resultados": len(reactivos)}})
    return [r.model_dump() for r in reactivos]
Deberías ver (al llamar al endpoint, una línea así en stdout)
{"time": "2026-08-16T13:06:11.830166+00:00", "severity": "INFO", "logger": "inventario.api", "message": "buscar reactivos", "q": "taq", "resultados": 0}
Qué NO va en un log. Llaves, contraseñas, datos de personas. El log viaja, se guarda meses y lo lee más gente de la que crees. Regla: si no lo pondrías en un correo a toda la empresa, no va en el log. Y fíjate en datetime.now(UTC): ruff (reglas DTZ) obliga a la zona horaria explícita; es el antipatrón de fechas sin zona. Pregúntale a Rodrigo qué le pasa a un sistema en un servidor UTC a las 7 pm de Lima: tiene historias.

Paso 3 API keys: se guarda el hash, nunca la llave

Autenticación = quién eres. Nuestra versión mínima seria: una llave por persona o sistema, revocable, guardada como hash. El modelo:

src/inventario/models.py (agregar)
class ApiKey(SQLModel, table=True):
    """Una llave por persona o sistema. Guardamos el hash, nunca la llave."""

    id: int | None = Field(default=None, primary_key=True)
    nombre: str = Field(unique=True, index=True)
    key_hash: str = Field(unique=True, index=True)
    activa: bool = True
    creada_en: datetime
src/inventario/auth.py
"""API keys: se genera una vez, se guarda su hash, se compara el hash.

Si alguien se roba la base de datos no obtiene las llaves, solo sus huellas.
"""

import hashlib
import secrets
from datetime import UTC, datetime

from sqlmodel import Session, select

from inventario.models import ApiKey


def generar_key() -> str:
    return "inv_" + secrets.token_urlsafe(32)


def hash_key(key: str) -> str:
    return hashlib.sha256(key.encode()).hexdigest()


def crear_api_key(session: Session, nombre: str) -> tuple[ApiKey, str]:
    """Devuelve el registro y la llave EN CLARO. Es la única vez que se ve."""
    key = generar_key()
    registro = ApiKey(nombre=nombre, key_hash=hash_key(key), creada_en=datetime.now(UTC))
    session.add(registro)
    session.commit()
    session.refresh(registro)
    return registro, key


def validar_api_key(session: Session, key: str) -> ApiKey | None:
    registro = session.exec(select(ApiKey).where(ApiKey.key_hash == hash_key(key))).first()
    if registro is None or not registro.activa:
        return None
    return registro


def revocar_api_key(session: Session, nombre: str) -> bool:
    registro = session.exec(select(ApiKey).where(ApiKey.nombre == nombre)).first()
    if registro is None:
        return False
    registro.activa = False
    session.add(registro)
    session.commit()
    return True

Crea la migración para la tabla nueva y aplícala, y agrega la gestión de llaves al CLI:

uv run alembic revision --autogenerate -m "tabla api_key"
uv run alembic upgrade head
src/inventario/cli.py (agregar)
keys = typer.Typer(help="Gestión de API keys")
app.add_typer(keys, name="keys")


@keys.command("create")
def keys_create(nombre: str) -> None:
    """Crea una llave y la muestra UNA vez. Guárdala en tu gestor de contraseñas."""
    with get_session() as session:
        _, key = crear_api_key(session, nombre)
    typer.echo(f"API key para {nombre} (no se volverá a mostrar):\n{key}")


@keys.command("revoke")
def keys_revoke(nombre: str) -> None:
    with get_session() as session:
        ok = revocar_api_key(session, nombre)
    if not ok:
        typer.echo(f"No existe la llave {nombre}", err=True)
        raise typer.Exit(1)
    typer.echo(f"Llave {nombre} revocada")
uv run inv keys create rodrigo
Deberías ver
API key para rodrigo (no se volverá a mostrar):
inv_vPamq7T1LJIAZakMG6rfSn469sF8lRox2uO3mV_0B98
Por qué hash y no la llave. La misma razón por la que las contraseñas jamás se guardan en claro: la base se respalda, se filtra, la lee más gente que la app. Con el hash, validar es posible (hasheas lo que llega y comparas) pero recuperar la llave no; por eso el CLI la muestra una sola vez. La alternativa simple —una lista en la variable INVENTARIO_API_KEYS— es válida para un servicio interno chico; elegimos tabla porque queremos revocar sin redesplegar. Anótalo en DECISIONES.md.

Paso 4 Proteger los endpoints: 401 por defecto

Una dependencia de FastAPI que corre antes que cualquier endpoint del router:

src/inventario/api/deps.py (agregar)
def require_api_key(
    x_api_key: str | None = Header(default=None),
    session: Session = Depends(get_db),
) -> ApiKey:
    """401 si falta la llave, no existe o está revocada. Un solo mensaje: no damos pistas."""
    if x_api_key is None:
        raise HTTPException(status_code=401, detail="Falta el header X-API-Key")
    registro = validar_api_key(session, x_api_key)
    if registro is None:
        raise HTTPException(status_code=401, detail="API key inválida o revocada")
    return registro

Se aplica a routers completos, no endpoint por endpoint (así no se te escapa ninguno). /health queda público a propósito:

src/inventario/api/routers/reactivos.py (fragmento)
router = APIRouter(
    prefix="/reactivos", tags=["reactivos"], dependencies=[Depends(require_api_key)]
)

Tests: los cinco casos que importan. El fixture api_key crea una llave real; una prueba la revoca:

tests/test_auth.py
def test_health_es_publico(client):
    r = client.get("/health")
    assert r.status_code == 200


def test_sin_api_key_401(client):
    r = client.get("/reactivos")
    assert r.status_code == 401
    assert "X-API-Key" in r.json()["detail"]


def test_api_key_invalida_401(client):
    r = client.get("/reactivos", headers={"X-API-Key": "inv_invente-esta-llave"})
    assert r.status_code == 401


def test_api_key_valida_200(client, session, api_key):
    services.crear_reactivo(session, "Taq polimerasa", "µL")
    r = client.get("/reactivos", params={"q": "taq"}, headers={"X-API-Key": api_key})
    assert r.status_code == 200
    assert r.json()[0]["nombre"] == "Taq polimerasa"


def test_api_key_revocada_401(client, session, api_key):
    revocar_api_key(session, "tests")
    r = client.get("/reactivos", headers={"X-API-Key": api_key})
    assert r.status_code == 401
uv run pytest -q
Deberías ver
6 passed, 1 warning in 0.03s

Pruébalo también en vivo, como lo hará Rodrigo:

uv run uvicorn inventario.api.main:app --port 8000 &
curl -s http://127.0.0.1:8000/health
curl -s -w " [%{http_code}]" http://127.0.0.1:8000/reactivos
curl -s -w " [%{http_code}]" -H "X-API-Key: inv_TU_LLAVE" "http://127.0.0.1:8000/reactivos?q=taq"
Deberías ver
{"status":"ok","version":"dev"}
{"detail":"Falta el header X-API-Key"} [401]
[] [200]
Autenticación vs autorización. Hoy resolviste "quién eres" (llave válida = entras). "Qué puedes hacer" (Rodrigo lee pero no borra) es autorización, y aquí todo el que entra puede todo: para este sistema alcanza, y en el P10 vuelve el tema cuando un agente tenga herramientas de escritura. No olvides actualizar el InventarioClient del P5 para mandar el header (ya aceptaba api_key en el constructor) y sumar un test con respx del caso 401.

Paso 5 Postgres en compose: el cambio que no duele

SQLite te sirvió tres proyectos. Ahora habrá varios clientes a la vez y un servidor efímero (Cloud Run puede apagar tu contenedor: un archivo .db adentro se pierde). Postgres es la respuesta aburrida y correcta. En compose:

compose.yaml
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: inventario
      POSTGRES_PASSWORD: solo-para-dev   # en prod esto vive en un gestor de secretos
      POSTGRES_DB: inventario
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U inventario"]
      interval: 2s
      timeout: 2s
      retries: 15

  api:
    build: .
    environment:
      INVENTARIO_DB_URL: postgresql+psycopg://inventario:solo-para-dev@db:5432/inventario
      INVENTARIO_ENV: dev
      INVENTARIO_LOG_LEVEL: INFO
    ports:
      - "8000:8000"
    depends_on:
      db:
        condition: service_healthy

volumes:
  pgdata:
docker compose up -d --build
docker compose ps
docker compose exec api uv run alembic upgrade head
docker compose exec api uv run inv keys create alexandra
curl -s http://127.0.0.1:8000/health
curl -s -w " [%{http_code}]" -H "X-API-Key: inv_LA_LLAVE_QUE_SALIÓ" http://127.0.0.1:8000/reactivos
Deberías ver
NAME                             STATUS
inventario-lab-api-1             Up
inventario-lab-db-1              Up (healthy)
API key para alexandra (no se volverá a mostrar):
inv_5lZdAx72k9GOJAqss2Tljfq1nN-2HhfEGDGA5R3-VEs
{"status":"ok","version":"dev"}
[] [200]
La prueba del buen diseño. Lo único que cambió para pasar de SQLite a Postgres fue INVENTARIO_DB_URL. Si te hubiera dolido —SQL a mano con sintaxis de SQLite, tipos raros— tu capa de datos estaba filtrando detalles del motor. Corre la suite completa contra Postgres una vez (INVENTARIO_DB_URL=postgresql+psycopg://… uv run pytest) para confirmarlo; el día a día sigue en SQLite en memoria porque es 100× más rápido. Y nota el @db de la URL: dentro de compose los servicios se encuentran por nombre, no por localhost — eso ya lo descubriste en el P5.

Paso 6 Deploy a Cloud Run con la base en Neon

Primero la base. En console.neon.tech: New project → nombre inventario, región AWS São Paulo (la más cercana). Copia la connection string (postgresql://usuario:contraseña@ep-….neon.tech/neondb?sslmode=require) y cámbiale el prefijo al driver que usamos: postgresql+psycopg://….

Esa URL contiene una contraseña: es un secreto. No va en el repo ni en una variable visible en la consola; va en Secret Manager:

printf '%s' 'postgresql+psycopg://usuario:CONTRASEÑA@ep-….neon.tech/neondb?sslmode=require' | \
  gcloud secrets create inventario-db-url --data-file=-

El deploy. Cloud Run construye la imagen desde tu Dockerfile, la publica y te da una URL con HTTPS:

gcloud run deploy inventario-api \
    --source . \
    --region southamerica-east1 \
    --allow-unauthenticated \
    --set-secrets INVENTARIO_DB_URL=inventario-db-url:latest \
    --set-env-vars INVENTARIO_ENV=prod,INVENTARIO_LOG_LEVEL=INFO,INVENTARIO_VERSION=$(git rev-parse --short HEAD) \
    --max-instances 2
Deberías ver
Service [inventario-api] revision [inventario-api-00001-xxx] has been deployed
and is serving 100 percent of traffic.
Service URL: https://inventario-api-XXXXXXXXXX.southamerica-east1.run.app

Aplica migraciones y crea las llaves contra la base de Neon, una vez, desde tu máquina:

INVENTARIO_DB_URL='postgresql+psycopg://…neon…' uv run alembic upgrade head
INVENTARIO_DB_URL='postgresql+psycopg://…neon…' uv run inv keys create rodrigo

Verifica desde afuera, y entrégale a Rodrigo su llave por un canal seguro (no por el mismo chat donde está el link del repo):

curl -s https://inventario-api-XXXXXXXXXX.southamerica-east1.run.app/health
curl -s -H "X-API-Key: inv_LLAVE_DE_RODRIGO" \
  "https://inventario-api-XXXXXXXXXX.southamerica-east1.run.app/lotes/por-vencer?dias=30"
--allow-unauthenticated no significa "sin auth". Le dice a Google que no exija cuenta de Google para llegar al servicio; tu API sigue exigiendo X-API-Key. --max-instances 2 es otro techo de gasto: si algo sale mal, Cloud Run no escala a cien contenedores. La alternativa a Neon es Cloud SQL con el proxy integrado (--add-cloudsql-instances): es el camino corporativo estándar, cuesta desde ~10 USD/mes, y está bien elegir lo gratis sabiendo que existe.

Paso 7 Deploy automático: main verde ⇒ prod

El deploy manual no se repite: ahora lo hace GitHub Actions en cada push a main, solo si los tests pasan. Autoriza a GitHub en tu proyecto de GCP con Workload Identity Federation —sin descargar llaves JSON; sigue la guía oficial de google-github-actions/auth— y agrega el workflow:

.github/workflows/deploy.yml
name: deploy
on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v6
      - run: uv sync
      - run: uv run ruff check .
      - run: uv run pytest

  deploy:
    needs: test            # sin verde no hay deploy
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write      # para autenticarse en GCP sin llaves guardadas
    steps:
      - uses: actions/checkout@v4
      - uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }}
          service_account: ${{ vars.GCP_SA_EMAIL }}
      - uses: google-github-actions/setup-gcloud@v2
      - run: |
          gcloud run deploy inventario-api \
            --source . \
            --region southamerica-east1 \
            --set-secrets INVENTARIO_DB_URL=inventario-db-url:latest \
            --set-env-vars INVENTARIO_ENV=prod,INVENTARIO_VERSION=${GITHUB_SHA::7}
Fíjate en INVENTARIO_VERSION=${GITHUB_SHA::7}. Cada deploy lleva el commit que lo produjo y /health lo devuelve: "¿qué versión está en prod?" deja de ser una adivinanza y pasa a ser un curl. Y needs: test es la regla de la guía hecha máquina: main rojo no se despliega.

Paso 8 Observar prod: provoca un error y encuéntralo

Ejercicio final. Provoca un 404 real contra prod, y encuéntralo en los logs sin abrir el código:

curl -s -w " [%{http_code}]" -H "X-API-Key: inv_TU_LLAVE" \
  -X POST https://inventario-api-….run.app/movimientos \
  -H "Content-Type: application/json" \
  -d '{"codigo_lote": "NO-EXISTE", "cantidad": 10}'

gcloud run services logs read inventario-api --region southamerica-east1 --limit 20
Deberías ver
{"detail":"Lote no existe: NO-EXISTE"} [404]
… y entre los logs, la línea JSON de ese request, con severity y hora UTC

Cierra con dos preguntas en DECISIONES.md: ¿cuánto tardaste en encontrar la línea? ¿qué campo le faltó al log para encontrarla más rápido? Agrégalo (por ejemplo, path y status en cada request). Ese ciclo —fallar, buscar, mejorar el log— es la mitad de operar un sistema.

Entiende el código

El flujo de un request autenticado

curl -H "X-API-Key: inv_abc…"  →  Cloud Run (HTTPS, enruta a tu contenedor)
  →  FastAPI: router /reactivos
       →  require_api_key: ¿sha256("inv_abc…") es un key_hash activo?  — no → 401, fin
       →  listar() → services.buscar_reactivo(session, q)   (la MISMA función que usa el CLI)
       →  log.info(…) → una línea JSON a stdout → Cloud Logging la indexa
  ←  200 [ … ]

Decisiones que quedaron tomadas

  • Llaves en tabla, no en env: revocables sin redesplegar; el costo es una consulta extra por request. Para este tráfico, nada.
  • sha256 simple, sin salt: correcto para llaves largas y aleatorias (no hay diccionario que las adivine). Para contraseñas humanas sería insuficiente: ahí va bcrypt/argon2. La distinción importa; pregúntasela a Claude.
  • Secretos en Secret Manager, config en env vars: la URL de la base lleva contraseña → secreto. El nivel de log no → variable normal. La línea divisoria: ¿pasa algo si sale en un screenshot?
  • Neon vs Cloud SQL: gratis y suficiente vs estándar corporativo. Se eligió a propósito, con la alternativa anotada.
  • lru_cache en get_settings() y get_engine(): la config se lee una vez por proceso y el engine (con su pool de conexiones) se crea una vez. Sin el cache, cada request abriría conexiones nuevas.

Qué es exactamente "un entorno"

La suma de: el código (commit X), la configuración (variables), los secretos, la base de datos y quién puede entrar. Dev y prod comparten solo lo primero. Cuando alguien dice "funciona en dev pero no en prod", la respuesta está en los otros cuatro; por eso /health devuelve la versión: descarta el primero de la lista en un segundo.

Con Claude

A esta altura Claude ya escribe partes del código contigo (reglas 2–5 de Trabajar con IA: pedir chico, leer el diff completo, test primero). En este proyecto úsalo además para lo que es especialmente bueno: revisar seguridad y explicar infraestructura que tocas por primera vez.

Pedidos que valen la pena en este proyecto

Revisa src/inventario/auth.py como auditor de seguridad: ¿qué lograría un atacante con acceso de solo lectura a mi base de datos? ¿Y con acceso al repo? ¿Por qué sha256 sin salt está bien para API keys aleatorias pero mal para contraseñas humanas? Explícamelo con números (espacio de búsqueda). Explícame línea por línea el workflow de deploy. ¿Qué es id-token: write y qué riesgo tendría guardar una llave JSON del service account como secret de GitHub? Dame un comando para auditar si alguna vez commiteé un secreto en este repo, y el plan exacto si aparece uno. Este es el log JSON de un request 404 [pegar línea]. ¿Qué tres campos le agregarías para depurar más rápido y por qué?

Y el examen de siempre: pídele cinco preguntas de criterio sobre entornos, secretos y auth, y que corrija tus respuestas con dureza.

Si algo falla

Ruff marca B008 Do not perform function call Depends in argument defaults

Depends(...) en los defaults es el patrón oficial de FastAPI, pero ruff no lo sabe de fábrica. Se le enseña en pyproject.toml:

[tool.ruff.lint.flake8-bugbear]
# Depends/Header en defaults es el patrón oficial de FastAPI, no un bug
extend-immutable-calls = ["fastapi.Depends", "fastapi.Header"]

Lección de criterio: el linter es una herramienta tuya, no tu jefe. Cuando marca algo correcto a propósito, se configura la excepción y se deja escrito el porqué.

Ruff marca DTZ011 datetime.date.today() used

Te está protegiendo del antipatrón de fechas sin zona: date.today() depende de la zona del servidor, y Cloud Run corre en UTC. Usa datetime.now(UTC).date() y decide conscientemente en qué zona se calcula "hoy". Para vencimientos de lotes, UTC consistente está bien; lo importante es que sea una decisión y no un accidente.

401 con una llave que acabas de crear

Casi siempre: la creaste en una base y la API mira otra. ¿La creaste con tu .env local (SQLite) pero la API corre en compose contra Postgres? Cada entorno tiene su propia tabla apikey: crea la llave donde la vas a usar (docker compose exec api uv run inv keys create … para compose; con la URL de Neon para prod). Revisa también espacios al copiar: el hash no perdona un espacio final.

psycopg.errors.UndefinedTable: relation "apikey" does not exist

Las tablas no existen en esa base. En Postgres nuevo hay que aplicar las migraciones: docker compose exec api uv run alembic upgrade head. Y una trampa sutil si usas create_all en un script: SQLModel.metadata solo conoce los modelos importados; un python -c "from inventario.db import init_db; init_db()" no importa models.py y crea… cero tablas, sin error. Por eso initdb es un comando del CLI (que sí importa los modelos) y prod usa alembic, siempre.

psycopg.OperationalError: connection failed al levantar compose

La API arrancó antes de que Postgres aceptara conexiones. Para eso están el healthcheck y el depends_on: condition: service_healthy: sin ellos, depends_on solo espera a que el contenedor exista. Si ya los tienes y falla, mira docker compose logs db.

Commiteaste un secreto (la URL de Neon, una llave)

Borrar el commit no alcanza: los bots escanean GitHub en minutos y el historial ya se clonó. El único fix real es rotar: en Neon, reset de la contraseña; para API keys, inv keys revoke y crear una nueva; actualizar Secret Manager. Después, si quieres, limpia el historial (git filter-repo) — pero rotar va primero. Audítate hoy: git log -p | grep -iE "password|secret|api_key|neon.tech".

gcloud run deploy falla con PERMISSION_DENIED o pide habilitar APIs

Falta una API o un rol. Repite el gcloud services enable … de "Antes de empezar" (tarda un par de minutos en propagarse la primera vez) y verifica el proyecto activo con gcloud config get-value project. Para el deploy desde Actions, el service account necesita run.admin, iam.serviceAccountUser, cloudbuild.builds.editor y secretmanager.secretAccessor sobre el secreto.

El servicio responde pero /health dice "version": "dev" en prod

El deploy no pasó INVENTARIO_VERSION. En el manual va en --set-env-vars; en Actions revisa el ${GITHUB_SHA::7}. Es cosmético para la app y letal para ti: sin versión visible vuelves a adivinar qué hay desplegado.

Limpieza

Como en todo tutorial de nube: al terminar, borra lo que no uses. Los recursos de esta guía caben en el free tier, pero el hábito es el punto.

# borrar el servicio de Cloud Run
gcloud run services delete inventario-api --region southamerica-east1

# borrar el secreto
gcloud secrets delete inventario-db-url

# o borrar TODO el proyecto de GCP de un golpe:
gcloud projects delete inventario-alexandra

En Neon el plan Free no cobra; borra el proyecto desde su consola si ya no lo quieres. Localmente: docker compose down (y con -v se borra también el volumen pgdata: eso es borrar datos — decide si es lo que quieres antes de teclearlo).

Si sigues al P7 esta semana, no limpies: el vigía usa esta misma API desplegada.

Listo cuando

Las mismas casillas que en el índice; marcarlas aquí las marca allá.

La pregunta de Rodrigo en la sesión

"Se filtró la connection string de Neon en un commit de hace dos semanas. Dime, en orden, las tres cosas que haces en los próximos diez minutos." (Pista: ninguna de las tres es git rebase.)

Siguiente

En el Proyecto 7 · Vigía de vencimientos esta API desplegada deja de esperar a que alguien le pregunte: un proceso corre solo cada mañana, consulta los lotes por vencer y avisa por Telegram. Aparecen las tres preguntas de los sistemas autónomos: ¿es idempotente?, ¿cómo sé que sigue vivo?, ¿qué pasa si muere a la mitad?