Apunta los scripts de sync prod al Hetzner nuevo y suma trabajo pendiente
- sync_translations_to_prod.py / sync_audio_to_prod.py / sync_carta_from_prod.py: migran de FEA_PROD_HOST/PASS (CDMON, password) a FEA_PROD_SSH_HOST/PASS + FEA_PROD_DOCKER_CONTAINER (Hetzner/Coolify, auth por clave), con wrapping docker exec y el fix del bug de redirecciones (wc -c/cat) resuelto en el host en vez de dentro del contenedor. - fea_translate_helper.php: subcomando clone_new para clonar en ID local nuevo cuando el ID de prod ya está ocupado localmente. - translate_post.py: --dry-run. - tts_produce.py: --allow-default-voice (voz Nico solo si se permite explícitamente para autores sin voz clonada) + fix voice_for_author. - minimax_tts.py: parámetro speed en t2a/_synth_chunk. - mirror-antiguo/deploy/nginx-mirror.conf: redirects de URLs históricas de catálogo y vista imprimible EFFA. - Importaciones humanas de Pagola (carta 738) + scripts/manifest de la fase A Enrique, y release_issue181_prod.sh / cdmon-retire-fewp1.sh. - .gitignore: excluye docs/backups/ (dumps SQL, mismo motivo que backups/*). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,9 +3,10 @@
|
||||
sync_audio_to_prod.py — Sube a PROD los mp3 de TTS ya generados/enlazados en
|
||||
local (fea_audio_done=1) y fija el meta fea_audio_url en prod.
|
||||
|
||||
Prod (134.0.10.170) tiene glibc rota: scp/sftp NO funcionan (connection closed).
|
||||
Workaround: subir el binario por stdin de ssh ("cat > ruta"), igual que el resto
|
||||
de scripts que tocan ese servidor (ver feadulta-server-glibc-rota.md).
|
||||
Prod vive en Hetzner (Coolify/Docker) desde el cutover de agosto 2026. Subida
|
||||
del binario por stdin de ssh ("cat > ruta" dentro del contenedor vía
|
||||
`docker exec -i`), sin depender de scp/sftp — mismo patrón que
|
||||
sync_translations_to_prod.py y sync_carta_from_prod.py.
|
||||
|
||||
Uso:
|
||||
python3 sync_audio_to_prod.py --carta 54254 # sincroniza toda la cola de la carta
|
||||
@@ -19,8 +20,10 @@ Rollback (despublica en prod lo que este script publicó):
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -31,13 +34,20 @@ DB_NAME = os.environ.get("FEA_DB_NAME", "wordpress_db")
|
||||
DB_USER = os.environ.get("FEA_DB_USER", "wordpress_user")
|
||||
DB_PASS = os.environ.get("FEA_DB_PASS", "wordpress_pass")
|
||||
|
||||
PROD_HOST = os.environ.get("FEA_PROD_HOST", "feadulta@134.0.10.170")
|
||||
PROD_PASS = os.environ.get("FEA_PROD_PASS", "C6c2A!mAl3Wj.BQF")
|
||||
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/web/wp-load.php")
|
||||
PROD_HELPER = "/tmp/fea_post_io.php"
|
||||
PROD_UPLOADS_TTS = "/web/wp-content/uploads/tts"
|
||||
PROD_HOST = os.environ.get("FEA_PROD_SSH_HOST", "")
|
||||
PROD_PASS = os.environ.get("FEA_PROD_SSH_PASS", "")
|
||||
PROD_WPLOAD = os.environ.get("FEA_PROD_WPLOAD", "/var/www/html/wp-load.php")
|
||||
# Desde el cutover a Hetzner, WordPress vive dentro de Coolify/Docker. Si se
|
||||
# define, el helper y la subida de mp3 se ejecutan dentro del contenedor.
|
||||
# Espejo de sync_translations_to_prod.py / sync_carta_from_prod.py.
|
||||
PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "")
|
||||
PROD_UPLOADS_TTS = os.environ.get("FEA_PROD_UPLOADS_TTS", "/var/www/html/wp-content/uploads/tts")
|
||||
|
||||
HELPER_SRC = Path(__file__).resolve().parent / "fea_post_io.php"
|
||||
# Se evalúa en memoria con `php -r`: no se copia un helper temporal a prod.
|
||||
HELPER_EVAL_CODE = "eval(base64_decode(" + repr(
|
||||
base64.b64encode(HELPER_SRC.read_text(encoding="utf-8").removeprefix("<?php").encode("utf-8")).decode("ascii")
|
||||
) + "));"
|
||||
LOCAL_TTS_DIR = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts"
|
||||
|
||||
LOG_FILE = Path(os.environ.get(
|
||||
@@ -74,6 +84,17 @@ def local_meta(post_id: int, key: str) -> str:
|
||||
return r.stdout.strip()
|
||||
|
||||
|
||||
def prod_id_for_local(local_id: int) -> int:
|
||||
"""Resolve a remapped local post to its original production ID.
|
||||
|
||||
``sync_carta_from_prod.py --remap-conflicts`` keeps this mapping in
|
||||
``fea_prod_source_id``. Audio files remain named with the local ID, but
|
||||
remote paths and production post meta must use the source ID.
|
||||
"""
|
||||
source_id = local_meta(local_id, "fea_prod_source_id")
|
||||
return int(source_id) if source_id.isdigit() else local_id
|
||||
|
||||
|
||||
def carta_article_ids(carta_id: int) -> list[int]:
|
||||
q = ("SELECT post_id FROM wp_postmeta "
|
||||
f"WHERE meta_key='_carta_id' AND meta_value='{carta_id}' ORDER BY post_id;")
|
||||
@@ -85,47 +106,80 @@ def carta_article_ids(carta_id: int) -> list[int]:
|
||||
return [int(x) for x in r.stdout.split() if x.isdigit()]
|
||||
|
||||
|
||||
# ── Prod (glibc rota: nada de scp/sftp, todo por ssh + cat) ────────────────────
|
||||
# ── Prod (Hetzner/Docker: todo por ssh, mp3 por stdin de "cat") ────────────────
|
||||
def _ssh_text(remote_cmd: str, *, stdin: str | None = None, timeout: int = 120) -> str:
|
||||
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||
# El Hetzner nuevo usa auth por clave (ed25519 claude-code@feadulta); sshpass
|
||||
# solo se antepone si hay contraseña configurada (servidor viejo CDMON).
|
||||
if PROD_PASS:
|
||||
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||
else:
|
||||
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||
r = subprocess.run(cmd, input=stdin, capture_output=True, text=True, timeout=timeout)
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError(f"ssh falló ({r.returncode}): {remote_cmd[:80]}…\n{r.stderr.strip()[:400]}")
|
||||
return r.stdout
|
||||
|
||||
|
||||
def _remote_wrap(inner_cmd: str) -> str:
|
||||
"""Envuelve un comando para que corra dentro del contenedor Docker de prod
|
||||
si FEA_PROD_DOCKER_CONTAINER está definido; si no, corre en el host tal cual.
|
||||
|
||||
Importante: el wrapping (incluidas redirecciones como `< ruta`) debe quedar
|
||||
DENTRO de la shell del contenedor (`sh -c '...'`), porque `docker exec
|
||||
CONTENEDOR cmd < ruta` resuelve esa redirección en el filesystem del HOST,
|
||||
no dentro del contenedor.
|
||||
"""
|
||||
if PROD_DOCKER_CONTAINER:
|
||||
return f"docker exec -i {shlex.quote(PROD_DOCKER_CONTAINER)} sh -c {shlex.quote(inner_cmd)}"
|
||||
return inner_cmd
|
||||
|
||||
|
||||
def _ssh_upload_bytes(data: bytes, remote_path: str, *, timeout: int = 180) -> None:
|
||||
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "ConnectTimeout=20", PROD_HOST, f"cat > {remote_path}"]
|
||||
remote_cmd = _remote_wrap(f"cat > {remote_path}")
|
||||
if PROD_PASS:
|
||||
cmd = ["sshpass", "-p", PROD_PASS, "ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||
else:
|
||||
cmd = ["ssh", "-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "ConnectTimeout=20", PROD_HOST, remote_cmd]
|
||||
sh(cmd, input_bytes=data, timeout=timeout)
|
||||
|
||||
|
||||
_prod_helper_ready = False
|
||||
|
||||
|
||||
def prod_helper(subcmd: str, *args: str) -> str:
|
||||
global _prod_helper_ready
|
||||
if not _prod_helper_ready:
|
||||
_ssh_upload_bytes(HELPER_SRC.read_bytes(), PROD_HELPER)
|
||||
_prod_helper_ready = True
|
||||
inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args)
|
||||
return _ssh_text(inner, timeout=60)
|
||||
"""Ejecuta el helper PHP en memoria; no deja ficheros temporales en prod."""
|
||||
argv = " ".join(shlex.quote(x) for x in (subcmd, *args))
|
||||
inner = (
|
||||
f"FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} "
|
||||
f"php -r {shlex.quote(HELPER_EVAL_CODE)} {argv}"
|
||||
)
|
||||
if PROD_DOCKER_CONTAINER:
|
||||
remote_cmd = (
|
||||
f"docker exec -i -e FEA_WP_LOAD={shlex.quote(PROD_WPLOAD)} "
|
||||
f"{shlex.quote(PROD_DOCKER_CONTAINER)} php -r {shlex.quote(HELPER_EVAL_CODE)} {argv}"
|
||||
)
|
||||
else:
|
||||
remote_cmd = inner
|
||||
return _ssh_text(remote_cmd, timeout=60)
|
||||
|
||||
|
||||
def prod_upload_mp3(post_id: int) -> None:
|
||||
src = LOCAL_TTS_DIR / f"{post_id}.mp3"
|
||||
def prod_upload_mp3(local_id: int, prod_id: int) -> None:
|
||||
src = LOCAL_TTS_DIR / f"{local_id}.mp3"
|
||||
data = src.read_bytes()
|
||||
remote_path = f"{PROD_UPLOADS_TTS}/{post_id}.mp3"
|
||||
remote_path = f"{PROD_UPLOADS_TTS}/{prod_id}.mp3"
|
||||
_ssh_upload_bytes(data, remote_path)
|
||||
# Verificación de tamaño (glibc rota => sin fiarse ciegamente del rc=0 de ssh)
|
||||
remote_size = int(_ssh_text(f"wc -c < {remote_path}").strip())
|
||||
# Verificación de tamaño: no fiarse ciegamente del rc=0 de ssh. `wc -c` debe
|
||||
# correr (y resolver la redirección) DENTRO del contenedor — ver _remote_wrap.
|
||||
remote_size = int(_ssh_text(_remote_wrap(f"wc -c < {remote_path}")).strip())
|
||||
if remote_size != len(data):
|
||||
raise RuntimeError(f"tamaño no coincide tras subir #{post_id}: local={len(data)} remoto={remote_size}")
|
||||
raise RuntimeError(
|
||||
f"tamaño no coincide tras subir prod#{prod_id}: local={len(data)} remoto={remote_size}"
|
||||
)
|
||||
|
||||
|
||||
def prod_remove_mp3(post_id: int) -> None:
|
||||
_ssh_text(f"rm -f {PROD_UPLOADS_TTS}/{post_id}.mp3")
|
||||
_ssh_text(_remote_wrap(f"rm -f {PROD_UPLOADS_TTS}/{post_id}.mp3"))
|
||||
|
||||
|
||||
# ── Estado ───────────────────────────────────────────────────────────────────
|
||||
@@ -149,25 +203,27 @@ def sync_one(post_id: int, state: dict, *, dry_run: bool) -> str:
|
||||
return "sin-audio-local"
|
||||
if not (LOCAL_TTS_DIR / f"{post_id}.mp3").exists():
|
||||
return "mp3-local-ausente"
|
||||
prod_id = prod_id_for_local(post_id)
|
||||
if dry_run:
|
||||
return "PLAN: subiría mp3 + setaudio"
|
||||
return f"PLAN: local#{post_id}.mp3 → prod#{prod_id}.mp3 + setaudio"
|
||||
|
||||
voice = local_meta(post_id, "fea_audio_voice") or "NicoFeadulta2026"
|
||||
prod_upload_mp3(post_id)
|
||||
prod_helper("setaudio", str(post_id), f"/wp-content/uploads/tts/{post_id}.mp3", voice)
|
||||
if post_id not in state["synced"]:
|
||||
state["synced"].append(post_id)
|
||||
prod_upload_mp3(post_id, prod_id)
|
||||
prod_helper("setaudio", str(prod_id), f"/wp-content/uploads/tts/{prod_id}.mp3", voice)
|
||||
if prod_id not in state["synced"]:
|
||||
state["synced"].append(prod_id)
|
||||
save_state(state)
|
||||
return "ok"
|
||||
return f"ok prod#{prod_id}"
|
||||
|
||||
|
||||
def rollback_one(post_id: int, state: dict) -> str:
|
||||
prod_helper("unsetaudio", str(post_id))
|
||||
prod_remove_mp3(post_id)
|
||||
if post_id in state["synced"]:
|
||||
state["synced"].remove(post_id)
|
||||
prod_id = prod_id_for_local(post_id)
|
||||
prod_helper("unsetaudio", str(prod_id))
|
||||
prod_remove_mp3(prod_id)
|
||||
if prod_id in state["synced"]:
|
||||
state["synced"].remove(prod_id)
|
||||
save_state(state)
|
||||
return "ok"
|
||||
return f"ok prod#{prod_id}"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -194,7 +250,7 @@ def main() -> int:
|
||||
for pid in ids:
|
||||
try:
|
||||
if args.rollback:
|
||||
if pid not in state["synced"] and not args.ids:
|
||||
if prod_id_for_local(pid) not in state["synced"] and not args.ids:
|
||||
res = "no-estaba-sincronizado"
|
||||
skip += 1
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user