# 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/ <destino>/
```
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.
