diff --git a/docs/cutover/RUNBOOK-cutover-wordpress.md b/docs/cutover/RUNBOOK-cutover-wordpress.md new file mode 100755 index 0000000..e46219d --- /dev/null +++ b/docs/cutover/RUNBOOK-cutover-wordpress.md @@ -0,0 +1,164 @@ +# Runbook del cutover — WordPress feadulta.com: CDMON → Hetzner + +**Fecha prevista: lunes 3 de agosto de 2026.** Plan maestro: gitea `rafa/feadulta`#180. +Todos los tiempos de abajo están **medidos en el ensayo del 31-jul**, no estimados. + +--- + +## Antes de empezar (verde obligatorio) + +- [ ] **Freeze editorial** avisado a Inma. El cutover no puede caer el día de publicación de la carta. +- [ ] **CDMON sigue vivo** (§1). Es el rollback: si no está, no se ejecuta. +- [ ] **Modo SSL de la zona en Cloudflare = Full (strict)**. Si está en *Flexible*, Cloudflare habla + HTTP con el origen mientras Traefik fuerza HTTPS → **bucle de redirección y sitio caído**. + Nuestro token no puede leer este ajuste: lo confirma Inma. +- [ ] **Inventario del wildcard.** La zona tiene `*.feadulta.com` → apex, proxied. Al mover el A del + apex, **todo subdominio no declarado se va a Hetzner** y Traefik devolverá su 404. Comprobado + pendiente: `info.feadulta.com`. + +## Tiempos medidos (ensayo del 31-jul) + +| Fase | Medido | ¿Se repite el lunes? | +|---|---|---| +| Dump CDMON → WSL (105 MB) | 25 s | **Sí** | +| WSL → Hetzner | 74 s | **Sí** | +| Saneo de collations + exclusión `*_bak` | 2 s | **Sí** | +| Import en MySQL 8.4 | 107 s | **Sí** | +| Instalar plugins + tema + 28 mu-plugins | 4 min 19 s | **No** (ya hecho) | +| Pre-sync de `uploads` 5,9 GB | ver #180 | **No** (ya hecho; el lunes solo el delta) | +| **Camino crítico de la ventana** | **≈ 3 min 30 s + delta de uploads** | | + +> El corte real percibido por el visitante es **solo el cambio del A record**, que en Cloudflare es +> instantáneo. Todo lo de arriba se hace **antes**, con el sitio viejo aún sirviendo. + +--- + +## 1. Backup de última hora en el origen + +No fiarse del cron de UpdraftPlus de las 05:09: forzar uno nuevo o confirmar que el del día existe. + +```bash +sshpass -p "$FEA_PROD_SSH_PASS" ssh feadulta@134.0.10.170 \ + 'ls -lt /web/wp-content/updraft/*-db.gz | head -3' +``` + +## 2. Traer el dump del día + +```bash +bash scripts/cutover/01-traer-dump.sh # CDMON → WSL → Hetzner, con sha256 en los 3 puntos +``` +Dos saltos a propósito: `scp`/`sftp` no funcionan contra la jaula de CDMON, y no queremos dejar la +contraseña de CDMON escrita en el Hetzner. + +## 3. Sanear el dump ⚠️ el paso que no se puede saltar + +El origen es **MariaDB 11.8.6** y el destino **MySQL 8.4.10**. El dump trae **20 tablas con +collations `uca1400_*` que solo existen en MariaDB 11+**; MySQL las rechaza en el `CREATE TABLE` y +el import aborta. + +``` +utf8mb4_uca1400_ai_ci → utf8mb4_0900_ai_ci +utf8mb3_uca1400_ai_ci → utf8mb4_0900_ai_ci (+ CHARSET=utf8mb3 → utf8mb4) +utf8mb4_unicode_520_ci → sin tocar (válida en MySQL 8.4) +``` +También excluye las 5 tablas de respaldo (`wp_options_bak`, `wp_posts_bak`, `wp_postmeta_bak`, +`wp_usermeta_bak`, `wp_fea_date_slug_posts_backup`). + +**Verificación antes de importar** (las tres deben dar 0): +```bash +grep -c 'uca1400' feadulta-saneado.sql +grep -c 'utf8mb3' feadulta-saneado.sql +grep -oE '^CREATE TABLE `[^`]+`' feadulta-saneado.sql | grep -cE '_bak|backup' +``` + +## 4. Importar + +**`--default-character-set=utf8mb4` en el CLIENTE es obligatorio.** Omitirlo es exactamente lo que +produjo el mojibake `’` en summaraise. + +```bash +docker exec -i mysql-r2ssjifwj0r0ghyoqd528uaa \ + mysql --default-character-set=utf8mb4 -uroot -p"$RP" wordpress < feadulta-saneado.sql +``` + +Verificación: 29 tablas, y las filas deben cuadrar con +`(líneas que empiezan por " (") + (nº de sentencias INSERT)` de cada tabla — la primera fila de cada +`INSERT` va pegada al `VALUES`. **Ignora el `# Approximate rows expected` del dump**: es la +estimación de InnoDB y para `wp_posts` dice 64.160 cuando las filas reales son 43.941. + +## 5. Delta de uploads + +```bash +rsync -a --info=progress2 --stats --exclude='*.php' \ + -e "sshpass -p ... ssh" feadulta@134.0.10.170:/web/wp-content/uploads/ / +``` +Solo mueve lo publicado desde el pre-sync. **`--delete` únicamente tras revisar un `--dry-run`.** + +## 6. Higiene de seguridad — SIEMPRE después de cada import + +```bash +bash scripts/cutover/post-import-seguridad.sh +``` +El dump trae las cuentas de administrador con sus hashes, **incluidas las tres deshabilitadas en el +incidente #183**: reimportar las revive. El script rota la contraseña de los 6 administradores +(leyéndolas de `~/.hermes/profiles/feadulta/.env`), retira el rol a `pabloarias`/`josek`/`andrey`, y +limpia los cron huérfanos de Yoast y WP Profile Builder. + +Doble cinturón: además del rol retirado, el mu-plugin `fea-security-blocked-users.php` bloquea +`wp_authenticate_user` y `allow_password_reset` para esos tres IDs. **Va en el manifiesto de +`docs/cutover/mu-plugins-manifest.txt`, no en un glob `fea-*`** — un glob se lo dejaría fuera. + +## 7. Quitar el WP_HOME/WP_SITEURL del staging + +``` +Coolify → servicio feadulta-wp → borrar la variable WORDPRESS_CONFIG_EXTRA → redeploy +``` +**No hace falta ningún `search-replace`**: la base de datos ya trae `siteurl`/`home` = +`https://www.feadulta.com`, que es la URL final. La constante era solo para el staging. Esto ahorra +la pasada más lenta de todo el proceso. + +## 8. Cambiar el A record en Cloudflare + +```bash +# feadulta.com y www.feadulta.com → 188.40.120.157, MANTENIENDO proxied=true (naranja) +``` +El token **sí tiene permiso de escritura**, verificado el 31-jul creando `nuevo.feadulta.com`. +Después: purgar la caché de Cloudflare. + +## 9. Verificación en caliente + +- [ ] Portada en los 5 idiomas (`/`, `/en/`, `/fr/`, `/it/`, `/pt/`) — `/es/` responde 301, es lo normal. +- [ ] Carta de la semana, evangelios, EFFA, autores con avatar. +- [ ] Buscador (índice FULLTEXT) devuelve resultados. +- [ ] **Audio TTS** suena. +- [ ] Imágenes cargan (depende del delta de uploads). +- [ ] `wp-login.php` entra con la contraseña rotada, y **las 3 cuentas del incidente NO entran**. +- [ ] Certificado Let's Encrypt para apex y `www`. +- [ ] Cero errores PHP en la portada y en `docker logs`. +- [ ] GA4 en tiempo real. + +## 10. Application passwords — con Inma delante, no antes + +**Solo existe una en todo el sitio**: `publicabot`, del usuario `icalvotorre` (ID 1048), creada el +27-jun-2026, último uso el 29-jul-2026 desde `213.94.23.1`. Es la que usan los bots de la carta. + +Rotarla sin coordinar **para la publicación de la carta semanal** y nadie sabrá por qué. Se rota con +Inma delante, generando la nueva y actualizándola en el bot en el mismo momento. + +*(Rotar la contraseña de un usuario **no** revoca sus application passwords: el paso 6 no la afecta.)* + +--- + +## Rollback + +**Revertir el A record en Cloudflare + purgar caché.** Segundos. El Joomla/WordPress de CDMON sigue +intacto y sirviendo — comprobado con `curl --resolve` contra `134.0.10.170`. + +**Esto solo funciona mientras CDMON siga vivo.** Es la razón por la que el §1 no es opcional. + +## Después (no el mismo día) + +- **Backups en Hetzner: hoy NO HAY NINGUNO.** La tabla `scheduled_database_backups` de Coolify está + vacía y no hay cron. Summaraise lleva semanas así. Hay que darle dueño. +- Actualizar los scripts y guías que apuntan a `134.0.10.170` y a `/web/` (cierra #158). +- Retirar `nuevo.feadulta.com` de Cloudflare cuando ya no haga falta. diff --git a/scripts/cutover/post-import-seguridad.sh b/scripts/cutover/post-import-seguridad.sh new file mode 100755 index 0000000..4d5973c --- /dev/null +++ b/scripts/cutover/post-import-seguridad.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# +# Higiene de seguridad posterior a importar la base de datos en el servidor nuevo. +# Parte del cutover CDMON -> Hetzner (gitea rafa/feadulta #180, criterio del comment-516). +# +# SE EJECUTA DESPUES DE CADA IMPORT. El dump trae las cuentas de administrador de +# produccion con sus hashes, incluidas las tres deshabilitadas durante el incidente #183, +# asi que reimportar las revive. Por eso esto es un script y no una tarea manual. +# +# Lee las contrasenas de ~/.hermes/profiles/feadulta/.env (claves FEA_WP_PASS_*). +# Nunca las imprime ni las pasa por argv: viajan por variable de entorno del proceso. +# +# Uso: bash post-import-seguridad.sh [--dry-run] +# +set -euo pipefail + +DRY=0 +[ "${1:-}" = "--dry-run" ] && DRY=1 + +ENVF=~/.hermes/profiles/feadulta/.env +[ -f "$ENVF" ] || { echo "Falta $ENVF"; exit 1; } +set -a; . "$ENVF"; set +a + +SRV=root@188.40.120.157 +U=r2ssjifwj0r0ghyoqd528uaa # servicio feadulta-wp en Coolify +WPC=wordpress-$U + +# usuario:ID:variable_de_password +ADMINS_OK="eqpyk:1:FEA_WP_PASS_EQPYK icalvotorre:1048:FEA_WP_PASS_ICALVOTORRE calvo:396:FEA_WP_PASS_CALVO" +ADMINS_KO="pabloarias:1047:FEA_WP_PASS_PABLOARIAS josek:1049:FEA_WP_PASS_JOSEK andrey:1087:FEA_WP_PASS_ANDREY" + +wp() { ssh -o BatchMode=yes "$SRV" "docker exec -u www-data $WPC php wp-content/wp-cli.phar --path=/var/www/html --skip-plugins --skip-themes $*" 2>/dev/null; } + +echo "=== 1. Rotar la contrasena de los 6 administradores ===" +for entry in $ADMINS_OK $ADMINS_KO; do + login=${entry%%:*}; rest=${entry#*:}; id=${rest%%:*}; var=${rest##*:} + pass="${!var:-}" + [ -n "$pass" ] || { echo " $login: falta $var en el .env"; exit 1; } + if [ $DRY -eq 1 ]; then + echo " [dry-run] rotaria la contrasena de $login (ID $id)" + else + ssh -o BatchMode=yes "$SRV" \ + "docker exec -e NEWPASS=\"$pass\" -u www-data $WPC php wp-content/wp-cli.phar --path=/var/www/html --skip-plugins --skip-themes eval 'wp_set_password(getenv(\"NEWPASS\"), $id);'" 2>/dev/null + echo " $login (ID $id): contrasena rotada" + fi +done + +echo "" +echo "=== 2. Deshabilitar las tres cuentas del incidente #183 ===" +echo " (doble cinturon: se les quita el rol Y el mu-plugin fea-security-blocked-users.php" +echo " bloquea wp_authenticate_user y allow_password_reset para estos mismos IDs)" +for entry in $ADMINS_KO; do + login=${entry%%:*}; rest=${entry#*:}; id=${rest%%:*} + if [ $DRY -eq 1 ]; then + echo " [dry-run] quitaria el rol administrator a $login (ID $id)" + else + wp user remove-role "$id" administrator >/dev/null || true + echo " $login (ID $id): rol administrator retirado" + fi +done + +echo "" +echo "=== 3. Limpiar los cron huerfanos de plugins ya desinstalados ===" +for hook in wpseo-reindex wpseo_permalink_structure_check wppb_review_check cozmos_wppb_plugin_optin_sync; do + if [ $DRY -eq 1 ]; then + echo " [dry-run] borraria el evento $hook" + else + out=$(wp cron event delete "$hook" 2>&1 || true) + echo " $hook: ${out:-sin eventos}" + fi +done + +echo "" +echo "=== 4. Verificacion ===" +echo " administradores que quedan:" +wp user list --role=administrator --fields=ID,user_login,user_email --format=csv | sed 's/^/ /' +echo " el mu-plugin de bloqueo esta presente?:" +ssh -o BatchMode=yes "$SRV" "docker exec $WPC test -f /var/www/html/wp-content/mu-plugins/fea-security-blocked-users.php && echo ' SI' || echo ' NO -- FALTA, las cuentas del incidente quedarian solo con el rol retirado'" +echo "" +echo " application passwords existentes (NO se tocan: las usan los bots de la carta;" +echo " su rotacion se coordina con Inma):" +wp db query "SELECT u.user_login, LENGTH(m.meta_value) AS bytes FROM wp_usermeta m JOIN wp_users u ON u.ID=m.user_id WHERE m.meta_key='_application_passwords';" 2>/dev/null | sed 's/^/ /' || \ + echo " (wp db query no disponible; consultar por SQL directo)" diff --git a/tools/e2e/sites/nuevo.json b/tools/e2e/sites/nuevo.json new file mode 100644 index 0000000..b527c71 --- /dev/null +++ b/tools/e2e/sites/nuevo.json @@ -0,0 +1,5 @@ +{ + "name": "feadulta-staging-hetzner", + "baseUrl": "https://nuevo.feadulta.com", + "notes": "Staging del cutover CDMON -> Hetzner (#180). Grey cloud, sin proxy de Cloudflare." +}