diff --git a/scripts/cutover/01-traer-dump.sh b/scripts/cutover/01-traer-dump.sh new file mode 100755 index 0000000..630e9e4 --- /dev/null +++ b/scripts/cutover/01-traer-dump.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# +# Paso 1-2 del cutover: traer el dump de BD del dia. CDMON -> WSL -> Hetzner. +# +# Dos saltos a proposito: scp/sftp no funcionan contra la jaula de CDMON, y no +# queremos dejar la contrasena de CDMON escrita en el Hetzner. Se verifica el +# sha256 en los tres puntos y la integridad del gzip en dos. +# +# Uso: bash 01-traer-dump.sh [nombre-del-dump] +# sin argumento coge el mas reciente de /web/wp-content/updraft/ +# +set -euo pipefail +set -a; . ~/.hermes/profiles/feadulta/.env 2>/dev/null; set +a + +WORK=/home/rafa/migracion-wp +SRV=root@188.40.120.157 +ORIG=feadulta@134.0.10.170 +mkdir -p "$WORK" + +s() { sshpass -p "$FEA_PROD_SSH_PASS" ssh -o StrictHostKeyChecking=accept-new "$ORIG" "$@"; } + +DUMP="${1:-}" +if [ -z "$DUMP" ]; then + # sin xargs ni basename: la jaula de CDMON tiene un PATH minimo + DUMP=$(s 'ls -t /web/wp-content/updraft/*-db.gz | head -1 | sed "s|.*/||"') +fi +echo "== dump elegido: $DUMP" + +echo "== en origen:" +s "php -r \"\\\$p='/web/wp-content/updraft/$DUMP'; echo ' ',filesize(\\\$p),' bytes sha256=',hash_file('sha256',\\\$p),\\\"\n\\\";\"" + +echo "== descargando a la WSL:" +T0=$(date +%s) +s "cat /web/wp-content/updraft/$DUMP" > "$WORK/$DUMP" +T1=$(date +%s); echo " descarga: $((T1-T0))s" + +echo "== integridad en la WSL:" +ls -l "$WORK/$DUMP" | awk '{print " ", $5, "bytes"}' +sha256sum "$WORK/$DUMP" | awk '{print " sha256=", $1}' +gzip -t "$WORK/$DUMP" && echo " gzip: integro" + +echo "== subiendo al Hetzner:" +ssh -o BatchMode=yes "$SRV" 'mkdir -p /data/feadulta-migracion' +T2=$(date +%s) +scp -q "$WORK/$DUMP" "$SRV:/data/feadulta-migracion/" +T3=$(date +%s); echo " subida: $((T3-T2))s" + +echo "== verificacion en destino:" +ssh -o BatchMode=yes "$SRV" "cd /data/feadulta-migracion && ls -l $DUMP | awk '{print \" \", \$5, \" bytes\"}' && sha256sum $DUMP | awk '{print \" sha256=\", \$1}' && gzip -t $DUMP && echo ' gzip: integro'" + +echo "" +echo "== total: $(( $(date +%s) - T0 ))s" +echo "$DUMP" > "$WORK/DUMP-DEL-DIA" +echo " (nombre guardado en $WORK/DUMP-DEL-DIA para el paso siguiente)" diff --git a/scripts/cutover/02-sanear-dump.sh b/scripts/cutover/02-sanear-dump.sh new file mode 100755 index 0000000..14d62fa --- /dev/null +++ b/scripts/cutover/02-sanear-dump.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# +# Paso 3 del cutover: sanear el dump. NO importa: eso es el paso 4. +# +# Dos cosas a la vez sobre el mismo streaming: +# - excluir las 5 tablas de respaldo (*_bak y wp_fea_date_slug_posts_backup) +# - mapear las collations de MariaDB 11 que MySQL 8.4 rechaza en el CREATE TABLE +# +# El troceado por tablas se apoya en los comentarios "# Table: `nombre`" que escribe +# UpdraftPlus. Si algun dia el dump lo genera mysqldump, esto hay que rehacerlo. +# +set -euo pipefail +DUMP=$(cat /home/rafa/migracion-wp/DUMP-DEL-DIA) +echo "== dump: $DUMP" +ssh -o BatchMode=yes root@188.40.120.157 "D='$DUMP' bash -s" <<'EOF' +set -euo pipefail +cd /data/feadulta-migracion +OUT=feadulta-saneado.sql +T0=$(date +%s) + +zcat "$D" \ +| awk ' + /^# Table: `/ { + name = $0; sub(/^# Table: `/, "", name); sub(/`.*$/, "", name) + skip = (name ~ /_bak$/ || name == "wp_fea_date_slug_posts_backup") ? 1 : 0 + if (skip) print " [excluida] " name > "/dev/stderr" + } + !skip { print } + ' \ +| sed -E \ + -e 's/utf8mb4_uca1400_ai_ci/utf8mb4_0900_ai_ci/g' \ + -e 's/utf8mb3_uca1400_ai_ci/utf8mb4_0900_ai_ci/g' \ + -e 's/CHARSET=utf8mb3/CHARSET=utf8mb4/g' \ + -e 's/CHARACTER SET utf8mb3/CHARACTER SET utf8mb4/g' \ + -e 's/utf8mb3_general_ci/utf8mb4_general_ci/g' \ + > "$OUT" + +echo "" +echo "== saneo en $(( $(date +%s) - T0 ))s" +ls -l "$OUT" | awk '{printf " %.1f MB sin comprimir\n", $5/1048576}' + +echo "" +echo "== VERIFICACION (las tres primeras deben dar 0)" +u=$(grep -c 'uca1400' "$OUT" || true); echo " restos de uca1400 : $u" +m=$(grep -c 'utf8mb3' "$OUT" || true); echo " restos de utf8mb3 : $m" +b=$(grep -oE '^CREATE TABLE `[^`]+`' "$OUT" | grep -cE '_bak|backup' || true); echo " tablas de respaldo: $b" +echo " tablas en el saneado: $(grep -cE '^CREATE TABLE ' "$OUT")" +echo " DROP TABLE IF EXISTS: $(grep -c '^DROP TABLE IF EXISTS' "$OUT" || true) <- si es 0, hay que vaciar la BD antes" +echo " collations resultantes:" +grep -oE 'COLLATE=[a-z0-9_]+' "$OUT" | LC_ALL=C sort | uniq -c | sed 's/^/ /' +echo " epilogo (debe verse el cierre del dump):" +tail -3 "$OUT" | sed 's/^/ /' + +[ "$u" = "0" ] && [ "$m" = "0" ] && [ "$b" = "0" ] || { echo ""; echo "!! VERIFICACION FALLIDA, no importar"; exit 1; } +echo "" +echo "== saneado OK" +EOF diff --git a/scripts/cutover/03-importar.sh b/scripts/cutover/03-importar.sh new file mode 100755 index 0000000..bc4f1c3 --- /dev/null +++ b/scripts/cutover/03-importar.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# +# Paso 4 del cutover: importar el dump saneado en MySQL 8.4. +# +# --default-character-set=utf8mb4 EN EL CLIENTE es obligatorio: omitirlo es lo que +# produjo el mojibake de summaraise. El dump trae DROP TABLE IF EXISTS en las 29 +# tablas, asi que no hace falta vaciar la BD antes. +# +set -euo pipefail +ssh -o BatchMode=yes root@188.40.120.157 'bash -s' <<'EOF' +set -euo pipefail +U=r2ssjifwj0r0ghyoqd528uaa +cd /data/feadulta-migracion +RP=$(docker inspect mysql-$U --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^MYSQL_ROOT_PASSWORD=//p') +# OJO: sin -i y con /dev/null WSL -> Hetzner, +# para no dejar la contrasena de CDMON escrita en el servidor. +# +# --partial y ServerAliveInterval porque un rsync largo contra este enlace se corta +# EN SILENCIO (la primera pasada del 31-jul murio a 36.289 de 47.920 sin error en el +# log). Por eso al final se comparan los recuentos de los dos lados: no vale con que +# rsync diga que termino. +# +# Los .php se excluyen a proposito: en uploads solo hay 2 y son importadores de un +# solo uso, no tienen que viajar. +# +set -euo pipefail +set -a; . ~/.hermes/profiles/feadulta/.env 2>/dev/null; set +a + +STAGE=/tmp/fea-uploads-staging +SRV=root@188.40.120.157 +DEST=/var/lib/docker/volumes/r2ssjifwj0r0ghyoqd528uaa_wordpress-files/_data/wp-content/uploads + +echo "== antes:" +printf " WSL : "; find "$STAGE" -type f | wc -l +printf " Hetzner : "; ssh -o BatchMode=yes "$SRV" "find $DEST -type f | wc -l" +printf " CDMON : "; sshpass -p "$FEA_PROD_SSH_PASS" ssh -o StrictHostKeyChecking=no feadulta@134.0.10.170 'find /web/wp-content/uploads -type f | wc -l' + +echo "" +echo "== delta CDMON -> WSL:" +T0=$(date +%s) +sshpass -p "$FEA_PROD_SSH_PASS" rsync -a --partial --info=stats2 --exclude='*.php' \ + -e "ssh -o StrictHostKeyChecking=accept-new -o ServerAliveInterval=20 -o ServerAliveCountMax=6" \ + feadulta@134.0.10.170:/web/wp-content/uploads/ "$STAGE/" \ + | grep -E 'Number of (regular files transferred|deleted)|Total transferred file size' | sed 's/^/ /' +echo " $(( $(date +%s) - T0 ))s" + +echo "" +echo "== delta WSL -> Hetzner (como www-data, uid 33):" +T1=$(date +%s) +rsync -a --partial --info=stats2 --exclude='*.php' --chown=33:33 \ + -e "ssh -o BatchMode=yes -o ServerAliveInterval=20 -o ServerAliveCountMax=6" \ + "$STAGE/" "$SRV:$DEST/" \ + | grep -E 'Number of (regular files transferred|deleted)|Total transferred file size' | sed 's/^/ /' +echo " $(( $(date +%s) - T1 ))s" + +echo "" +echo "== VERIFICACION por recuento (no fiarse de que rsync diga que termino):" +a=$(sshpass -p "$FEA_PROD_SSH_PASS" ssh -o StrictHostKeyChecking=no feadulta@134.0.10.170 'find /web/wp-content/uploads -type f ! -name "*.php" | wc -l') +b=$(find "$STAGE" -type f | wc -l) +c=$(ssh -o BatchMode=yes "$SRV" "find $DEST -type f | wc -l") +echo " CDMON (sin .php): $a" +echo " WSL : $b" +echo " Hetzner : $c" +[ "$a" = "$c" ] && echo " OK: origen y destino cuadran" || echo " !! NO CUADRAN, revisar antes de seguir" + +echo "" +echo "== propietario en destino (no debe haber nada que no sea www-data):" +ssh -o BatchMode=yes "$SRV" "find $DEST ! -user 33 | head -5 | sed 's/^/ /'; echo ' ficheros con otro dueno: '\$(find $DEST ! -user 33 | wc -l)" diff --git a/scripts/cutover/05-mover-dns.sh b/scripts/cutover/05-mover-dns.sh new file mode 100755 index 0000000..aaec772 --- /dev/null +++ b/scripts/cutover/05-mover-dns.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# +# Paso 8 del cutover: mover el A record del apex y de www a Hetzner. +# +# Se cambia SOLO el content. proxied=true se mantiene (la web va por Cloudflare) y +# no se toca nada mas de la zona: el MX, el wildcard, los NS de cdmon y los TXT +# (SPF y brevo) se quedan igual, asi que el correo no se ve afectado. +# +# ROLLBACK: volver a poner 134.0.10.170 en estos dos mismos ids. +# +set -uo pipefail +set -a; . ~/.hermes/profiles/feadulta/.env 2>/dev/null; set +a + +ZONE=5aa15f0f9cf7d45f3710177bb4a66aba +NUEVA_IP=188.40.120.157 +APEX=807be63daf5a2ce9cfc710dcc1ef0a21 +WWW=0ce31fc2fbf5316434ace0c944909c59 + +mover() { + local id=$1 name=$2 + curl -s -X PATCH \ + -H "Authorization: Bearer $FEA_CF_API_TOKEN" -H "Content-Type: application/json" \ + "https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records/$id" \ + --data "{\"content\":\"$NUEVA_IP\"}" \ + | python3 -c " +import sys,json +d=json.load(sys.stdin) +if d.get('success'): + r=d['result']; print(' OK %-20s -> %s proxied=%s' % (r['name'], r['content'], r['proxied'])) +else: + print(' FALLO %s: %s' % ('$name', d.get('errors'))) +" +} + +echo "== $(date '+%F %T %Z') moviendo los A records:" +mover "$APEX" feadulta.com +mover "$WWW" www.feadulta.com + +echo "" +echo "== estado en la zona tras el cambio:" +curl -s -H "Authorization: Bearer $FEA_CF_API_TOKEN" \ + "https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records?type=A&per_page=100" \ +| python3 -c ' +import sys,json +for r in json.load(sys.stdin)["result"]: + print(" %-22s %-16s proxied=%s" % (r["name"], r["content"], r["proxied"])) +' + +echo "" +echo "== purgando la cache de Cloudflare:" +curl -s -X POST \ + -H "Authorization: Bearer $FEA_CF_API_TOKEN" -H "Content-Type: application/json" \ + "https://api.cloudflare.com/client/v4/zones/$ZONE/purge_cache" --data '{"purge_everything":true}' \ +| python3 -c ' +import sys,json +d=json.load(sys.stdin) +print(" purga OK" if d.get("success") else " NO se pudo purgar: %s" % d.get("errors")) +print(" (si falla es que el token solo tiene permiso de DNS: hay que purgar desde el panel)") +'