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 verAPI 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.pydef 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
Deberías ver6 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.yamlservices:
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 verNAME 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 verService [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.ymlname: 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.
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.