Ir al contenido

Runbooks

Procedimientos operativos. Para cada uno: qué hace, cuándo se ejecuta, comandos exactos, qué verificar al final.

  1. Deploy de tcgcards-web (frontend)
  2. Deploy de tcgcards-api (backend)
  3. Rollback de un deploy
  4. Restaurar backup de Mongo
  5. Rotar un secret
  6. Agregar/quitar admin a un usuario
  7. Forzar la ejecución de un cron job
  8. Actualizar/agregar credenciales Mercado Pago
  9. Investigar un error en producción
  10. Cambiar dominio
  11. Saltó la alerta de refresh-prices

Cuándo: cada vez que quieres que un cambio del repo tcgcards-web llegue a tcgcards.cl.

Procedimiento (resumen):

  1. Push a la rama staging → Vercel publica en staging.tcgcards.cl (automático). Pruebas ahí.
  2. Fusiona la rama del featuremain (no despliega nada · nunca stagingmain).
  3. GitHub → tcgcards-webActions“Deploy Web → PROD (manual)” → Run workflow → main.

Fallback sin Actions: cd /Users/yoel/Documents/Repos/tcgcards-web && vercel --prod.

Cómo verificar:

  • El run de “Deploy Web → PROD” en verde; en su log aparece Aliased https://tcgcards.cl.
  • Abrir https://tcgcards.cl en incognito → verificar el cambio en producción.

Tiempo total: ~6-7 min (CI + build en Vercel).


Cuándo: cada vez que quieres que un cambio del repo tcgcards-api llegue a producción. Esto no es automático.

Procedimiento (resumen):

  1. Push a la rama staging → GitHub Actions despliega tcgcards-api-staging (automático). Pruebas ahí.
  2. Fusiona la rama del featuremain (no despliega nada · nunca stagingmain).
  3. GitHub → tcgcards-apiActions“Deploy API → PROD (manual)” → Run workflow → main. (Ejecuta CI y despliega por --image.)

Fallback sin Actions (build remoto en Cloud Build, igual destino):

Ventana de terminal
cd /Users/yoel/Documents/Repos/tcgcards-api
bash scripts/deploy-prod.sh
# equivale a: gcloud run deploy tcgcards-api --source . --region us-central1 --project api-cards-prod --quiet

Cómo verificar:

Ventana de terminal
# Status del servicio
gcloud run services describe tcgcards-api \
--region us-central1 \
--project api-cards-prod \
--format="value(status.conditions[0].type,status.conditions[0].status,status.latestReadyRevisionName)"
# Esperado: "Ready True tcgcards-api-XXXXX-xxx"
# Sanity check endpoint público
curl -s -o /dev/null -w "%{http_code}\n" \
"https://tcgcards-api-1033181994095.us-central1.run.app/api/v1/health"
# Esperado: 200

Tiempo total: 5-8 min (build + container push + revision routing).


  1. https://vercel.comtcgcards-web → Deployments
  2. Encuentra el deploy anterior bueno.
  3. Click en los ... → “Promote to Production”.

Toma ~30 segundos. No requiere CLI.

Ventana de terminal
# 1. Listar revisiones recientes
gcloud run revisions list \
--service tcgcards-api \
--region us-central1 \
--project api-cards-prod \
--limit 5
# 2. Apuntar 100% del tráfico a una revisión específica
gcloud run services update-traffic tcgcards-api \
--to-revisions=tcgcards-api-00128-gpd=100 \
--region us-central1 \
--project api-cards-prod

(Reemplaza tcgcards-api-00128-gpd por la revisión deseada.)

Cómo verificar: comando describe igual que en deploy.


Cuándo: se borró o corrompió data, o quieres ver/recuperar algo de un backup.

Conceptos en 30 segundos (si nunca hiciste esto)

Sección titulada «Conceptos en 30 segundos (si nunca hiciste esto)»
  • Backup / dump: una copia completa de la base de datos en un momento dado. Es un solo archivo: dump-AÑOMESDÍA-HORAMINSEG.archive.gz. La hora es en UTC (~4 h adelante de Chile).
  • Dónde viven: en Google Cloud Storage, en gs://tcgcards-mongo-backups/.
  • Restaurar: tomar ese archivo y cargarlo en una base MongoDB.
  • Local vs producción:
    • Local = una MongoDB “de juguete” en tu computador. No puede romper nada. Empieza siempre por acá.
    • Producción = la base real que usan los usuarios. Tocarla es peligroso.
  • Situación A — “Solo quiero MIRAR un backup o recuperar algún dato perdido”. → Seguro, no toca producción. Hazlo siempre primero.
  • Situación B — “Se borró/corrompió data en PRODUCCIÓN y hay que devolverla”. → Peligroso. Más abajo, con calma.

Paso 0 — Instalar herramientas (una sola vez)

Sección titulada «Paso 0 — Instalar herramientas (una sola vez)»

En una terminal (Mac):

Ventana de terminal
brew tap mongodb/brew
brew install mongodb-database-tools mongodb-community mongosh
brew install --cask google-cloud-sdk # solo si aún no tienes gcloud

Verifica que quedaron instaladas (cada comando debe imprimir una versión, no un error):

Ventana de terminal
mongorestore --version
mongosh --version
gcloud --version

Paso 0b — Conectarte a Google Cloud (una sola vez)

Sección titulada «Paso 0b — Conectarte a Google Cloud (una sola vez)»
Ventana de terminal
gcloud auth login # abre el navegador; inicia sesión con yoeldts2@gmail.com
gcloud config set project api-cards-prod
gcloud auth list # debe mostrar tu correo con un "*" al lado

Si gcloud auth list no muestra tu correo como activo, repite gcloud auth login.


Situación A — Restaurar en un Mongo local (no puedes romper nada)

Sección titulada «Situación A — Restaurar en un Mongo local (no puedes romper nada)»

A1. Ver los backups disponibles y elegir uno

Ventana de terminal
gcloud storage ls gs://tcgcards-mongo-backups/ | tail -20

Verás líneas como gs://tcgcards-mongo-backups/dump-20260529-211811.archive.gz. El 20260529-211811 es la fecha/hora UTC (29-may-2026, 21:18:11 UTC). Elige el más reciente de ANTES de que ocurriera el problema. Para usar el último de todos:

Ventana de terminal
ULTIMO=$(gcloud storage ls gs://tcgcards-mongo-backups/ | sort | tail -1)
echo "Voy a usar: $ULTIMO"

A2. Descargarlo a tu computador

Ventana de terminal
mkdir -p /tmp/restore
gcloud storage cp "$ULTIMO" /tmp/restore/dump.archive.gz

(Si elegiste uno específico, reemplaza "$ULTIMO" por la ruta completa gs://tcgcards-mongo-backups/dump-FECHA.archive.gz.)

A3. Confirmar que el archivo no está corrupto

Ventana de terminal
gzip -t /tmp/restore/dump.archive.gz && echo "OK: archivo íntegro"

Debe imprimir OK: archivo íntegro. Si da error, se dañó al bajar — bórralo y vuelve a descargar.

A4. Levantar una MongoDB de juguete (Terminal 1)

Abre una terminal y deja este comando corriendo. Va a quedar imprimiendo logs — es normal, NO cierres esta terminal:

Ventana de terminal
mkdir -p /tmp/mongo-juguete
mongod --dbpath /tmp/mongo-juguete --port 27018

A5. Cargar el backup en la base de juguete (Terminal 2)

Abre otra terminal (deja la anterior corriendo) y ejecuta:

Ventana de terminal
mongorestore --uri="mongodb://localhost:27018" \
--archive=/tmp/restore/dump.archive.gz --gzip

Qué deberías ver al final: una línea como 207445 document(s) restored successfully. 0 document(s) failed to restore. Lo importante es 0 document(s) failed.

A6. Mirar los datos

Ventana de terminal
mongosh "mongodb://localhost:27018/api-cards"

Ya dentro (verás un prompt >), prueba:

show collections // lista las colecciones
db.cards.countDocuments() // ~205000
db.orders.countDocuments()
// Buscar un documento específico que se haya perdido, por ejemplo una orden:
db.orders.findOne({ _id: "order-XXXXXXX" })
// O un usuario por correo:
db.users.findOne({ email: "persona@ejemplo.cl" })

Para salir de mongosh escribe exit.

A7. Limpiar al terminar

  1. Vuelve a la Terminal 1 y aprieta Ctrl+C (apaga la base de juguete).
  2. Borra los temporales:
Ventana de terminal
rm -rf /tmp/mongo-juguete /tmp/restore

Listo. No tocaste producción en ningún momento.


Situación B — Restaurar EN producción (PELIGROSO)

Sección titulada «Situación B — Restaurar EN producción (PELIGROSO)»

B1. Antes de tocar nada: backup del estado ACTUAL

Esto te da un “punto de retorno” por si la restauración empeora las cosas:

Ventana de terminal
gcloud run jobs execute mongo-backup \
--region=us-central1 --project=api-cards-prod --wait

Espera a que termine (exit 0). El estado actual quedó guardado en GCS.

B2. Decide el alcance (clave)

  • ¿Solo una colección quedó mal (ej. listings)? → restaura solo esa (Opción B-mín). Es lo más seguro: no pierdes el resto.
  • ¿Está toda la base corrupta/borrada? → restauración completa (Opción B-total). Último recurso.

B3. Obtener la conexión a producción y el dump

Ventana de terminal
URI=$(gcloud secrets versions access latest --secret=mongodb-uri --project=api-cards-prod)

Asegúrate de tener el dump elegido descargado en /tmp/restore/dump.archive.gz (Pasos A1–A3). Si no, hazlo ahora.

Opción B-mín — Restaurar UNA sola colección (recomendado)

Reemplaza listings por la colección afectada:

Ventana de terminal
mongorestore --uri="$URI" --gzip \
--archive=/tmp/restore/dump.archive.gz \
--nsInclude="api-cards.listings" --drop
  • --nsInclude="api-cards.listings" = restaura solo esa colección.
  • --drop = borra esa colección antes de restaurarla (la deja igual al backup).

Opción B-total — Restaurar TODA la base (último recurso)

Ventana de terminal
mongorestore --uri="$URI" --gzip \
--archive=/tmp/restore/dump.archive.gz --drop

B4. Verificar

Ventana de terminal
mongosh "$URI" --quiet --eval '
const d = db.getSiblingDB("api-cards");
print("cards: " + d.cards.countDocuments());
print("orders: " + d.orders.countDocuments());
print("wallet_entries: " + d.wallet_entries.countDocuments());
const sys = d.users.findOne({_id:"user-system-tcgcards"});
print("system wallet balance: " + (sys ? sys.walletBalance : "NO ENCONTRADO"));
'

Los números deberían tener sentido (cards ~205000) y el system wallet debe aparecer.

B5. Si quedó peor que antes

Tienes el backup del Paso B1: repite la restauración usando ese archivo (el que generaste en B1) para volver al estado previo.


RPO efectivo: ~1 hora (los backups son cada hora). Drill de restore verificado el 2026-05-29 (restauración completa, 0 documentos fallidos). El usuario de la app no puede dropear bases de datos (least-privilege), un freno extra contra borrados accidentales.


Ventana de terminal
# 1. Generar el nuevo valor (ejemplo para secret aleatorio de 32 bytes hex)
NEW_VALUE=$(openssl rand -hex 32)
# 2. Agregar como nueva versión
echo -n "$NEW_VALUE" | gcloud secrets versions add INTERNAL_JWT_SECRET \
--data-file=- \
--project=api-cards-prod
# 3. Verificar
gcloud secrets versions access latest \
--secret=INTERNAL_JWT_SECRET \
--project=api-cards-prod
# 4. Redeploy del Cloud Run para que aplique
cd /Users/yoel/Documents/Repos/tcgcards-api
gcloud run deploy tcgcards-api \
--source . \
--region us-central1 \
--project api-cards-prod \
--quiet
Ventana de terminal
# Listar
vercel env ls
# Quitar el viejo
vercel env rm INTERNAL_JWT_SECRET production
# Agregar el nuevo (interactive, pide valor)
vercel env add INTERNAL_JWT_SECRET production
# Redeploy para que aplique
vercel --prod

Cambiar valor sensible y mantener compatibilidad

Sección titulada «Cambiar valor sensible y mantener compatibilidad»

Para minimizar downtime al rotar secrets compartidos:

  1. Generar nuevo valor.
  2. Setear en backend + redeploy.
  3. Setear en frontend + redeploy.
  4. (Entre paso 2 y 3 hay ~2 min de incompatibilidad. Hacer en horario de menor tráfico.)

No hay UI para esto. Se hace por Mongo directo.

Ventana de terminal
# Conectar
mongosh "<MONGODB_URI>"
> use api-cards
# Hacer admin
> db.users.updateOne({ username: "ejemplo" }, { $set: { isAdmin: true } })
# Quitar admin
> db.users.updateOne({ username: "ejemplo" }, { $set: { isAdmin: false } })
# Verificar
> db.users.findOne({ username: "ejemplo" }, { isAdmin: 1, username: 1 })

El cambio aplica inmediatamente — el siguiente request del user con sesión activa lo verá como admin. No hace falta reiniciar nada.


Ventana de terminal
gcloud scheduler jobs run orders-tick \
--project=api-cards-prod \
--location=us-central1

Reemplaza orders-tick por el job que quieras. Lista completa:

  • mongo-backup-hourly
  • orders-tick
  • orders-awaiting-payment-cleanup
  • wallet-reconcile-daily
  • refunds-reconcile-daily

Cómo verificar:

Ventana de terminal
# Ver logs del Cloud Run del último minuto
gcloud run services logs read tcgcards-api \
--region=us-central1 \
--project=api-cards-prod \
--limit=50

Buscar líneas con prefijo cron. (e.g., cron.canceled, cron.completed).


Actualizar/agregar credenciales Mercado Pago

Sección titulada «Actualizar/agregar credenciales Mercado Pago»

Cuándo: se necesita rotar MP_ACCESS_TOKEN, el webhook secret, o cuando MP cambia algo del lado de ellos.

Ventana de terminal
# 1. Obtener token nuevo desde el panel MP → Credenciales productivas (o test si testing)
# El token es de la forma APP_USR-XXXXXX-XXXXXX-XXXXXX-XXXXXX
# 2. Actualizar el secret
echo -n "APP_USR-..." | gcloud secrets versions add MP_ACCESS_TOKEN \
--data-file=- \
--project=api-cards-prod
# 3. Redeploy
cd /Users/yoel/Documents/Repos/tcgcards-api
gcloud run deploy tcgcards-api --source . --region us-central1 --project api-cards-prod --quiet

Para el MP_WEBHOOK_SECRET:

  1. En el panel MP, configurar el webhook con el endpoint: https://tcgcards-api-1033181994095.us-central1.run.app/api/v1/webhooks/mp
  2. MP genera un secret — copiarlo
  3. echo -n "SECRET" | gcloud secrets versions add MP_WEBHOOK_SECRET --data-file=- --project=api-cards-prod
  4. Redeploy.

https://sentry.io → proyecto correspondiente (web o api) → buscar el error por mensaje o usuario.

Ventana de terminal
gcloud run services logs read tcgcards-api \
--region=us-central1 \
--project=api-cards-prod \
--limit=100 \
--format=json | jq '.[] | select(.severity == "ERROR")'

3. Mongo directo (datos del user/orden afectada)

Sección titulada «3. Mongo directo (datos del user/orden afectada)»
Ventana de terminal
mongosh "<MONGODB_URI>"
> use api-cards
> db.orders.findOne({ _id: "order-xxxxx" })
> db.users.findOne({ email: "afectado@example.com" }, { walletBalance: 1, status: 1 })

https://app.posthog.com → buscar por user properties (email/username).


Si alguna vez se cambia el dominio tcgcards.cl a otro:

  1. DNS en Cloudflare: apuntar el nuevo dominio a Vercel + Cloud Run.
  2. Vercel: agregar el nuevo dominio en el proyecto tcgcards-web → Settings → Domains.
  3. Env vars del web: actualizar NEXT_PUBLIC_SITE_URL → redeploy Vercel.
  4. Env vars del api: actualizar WEB_PUBLIC_URL (afecta los emails que mandamos) → redeploy Cloud Run.
  5. OAuth Google: agregar https://nuevo-dominio.cl/api/auth/callback/google a los Authorized Redirect URIs en Google Cloud Console.
  6. MP webhooks: registrar el nuevo URL en el panel MP.
  7. Resend: verificar el nuevo dominio (SPF + DKIM en Cloudflare DNS) para poder mandar emails como notificaciones@nuevo-dominio.
  8. Email Routing en Cloudflare: repetir setup soporte@ y disputas@ para el nuevo dominio.
  9. Sentry: agregar el nuevo dominio a CORS allowlist en cada proyecto.
  10. Cloudflare Access (docs): actualizar la aplicación de Zero Trust si el dominio de docs también cambia.

Es un cambio grande — hacer en ventana de mantenimiento, con OK explícito.


El correo dice «Cloud Run Job - logging/user/refresh_prices_heartbeat is below threshold». Significa una sola cosa: el job no registró una corrida limpia en ~30 h. La causa puede ser cualquiera de varias, y el propio correo sugiere tres que normalmente no son la correcta.

Ve en este orden. Cada paso descarta algo.

Ventana de terminal
gcloud scheduler jobs describe refresh-prices-daily --location=us-central1 \
--project=api-cards-prod --format='value(state,schedule,lastAttemptTime)'

Si state es ENABLED y lastAttemptTime es de hoy, el Scheduler está bien. Sigue.

2. ¿Cómo terminaron las últimas corridas?

Sección titulada «2. ¿Cómo terminaron las últimas corridas?»
Ventana de terminal
gcloud run jobs executions list --job=refresh-prices --region=us-central1 \
--project=api-cards-prod --limit=6 \
--format='table(name,status.startTime,status.completionTime)'
gcloud run jobs executions describe <EXECUTION> --region=us-central1 \
--project=api-cards-prod --format='value(status.conditions[0].status,status.conditions[0].message)'

Presta atención a la duración. El límite es de 3 h (timeoutSeconds: 10800). Si el mensaje dice «The configured timeout was reached», el job hace su trabajo pero no alcanza a terminar — que es distinto de fallar.

Ventana de terminal
gcloud logging read 'resource.type="cloud_run_job"
resource.labels.job_name="refresh-prices"
jsonPayload.msg="refresh-prices finished"' \
--project=api-cards-prod --limit=3 --order=desc --format=json

Los campos que importan:

CampoQué significa si está alto
setsSkippedUnrecognizedAparecieron sets huérfanos (TCGplayer renombró y nuestro nombre viejo quedó suelto). Es la causa más probable de que el job se ponga lento.
setsFailedFallos de red o bloqueo de TCGplayer
aborted: trueSaltó el circuit breaker (5 fallos seguidos)
cardsUpdatedProductos traídos de TCGplayer, no cartas nuestras. Si sube de golpe sin que crezca el catálogo, hay huérfanos.

Si no hay línea finished del día, el job murió antes de terminar.

Corre el script en seco (no escribe nada) para ver cuáles son:

Ventana de terminal
npx tsx scripts/purge-orphan-tcgplayer-sets.ts

Revisa la lista, y solo entonces:

Ventana de terminal
npx tsx scripts/purge-orphan-tcgplayer-sets.ts --confirm

Guarda copia en backups/ antes de borrar y verifica que el total de cartas no cambie. Ver el detalle en cron jobs.

Sin huérfanos y aun así pasado de 3 h, el catálogo creció de verdad. Tres salidas:

  • Bajar --min-interval-ms: acorta el recorrido sin dejar ningún set sin procesar, que es la ventaja sobre las otras dos. Ojo con el techo: TCGplayer responde en ~550 ms y el recorrido es secuencial, así que por debajo de eso no se gana nada. Ver el ritmo contra TCGplayer en cron jobs.
  • --mode=steady --max-minutes=N: procesa del más rancio al más fresco dentro de un presupuesto de tiempo y termina limpio siempre. Inmune al crecimiento futuro; a cambio, algunos sets esperan al día siguiente.
  • Subir timeoutSeconds: más rápido de aplicar, pero solo mueve el problema unas semanas.

No significa que los precios estén mal ni que falten cartas. Aun cortándose a mitad, el job procesa del más rancio al más fresco, así que lo que alcanzó a refrescar quedó correcto. Lo que sí conviene mirar es si auto-price (8 AM) empezó a saltar cartas por no_fresh_price: ese es el primer síntoma que llega al usuario.