diff --git a/scripts/sync_audio_to_prod.py b/scripts/sync_audio_to_prod.py index 211856f..fef4204 100644 --- a/scripts/sync_audio_to_prod.py +++ b/scripts/sync_audio_to_prod.py @@ -3,9 +3,9 @@ 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` si FEA_PROD_DOCKER_CONTAINER está definido). Uso: python3 sync_audio_to_prod.py --carta 54254 # sincroniza toda la cola de la carta @@ -31,11 +31,14 @@ 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_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 el mp3 se suben/ejecutan dentro del contenedor. +PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "") PROD_HELPER = "/tmp/fea_post_io.php" -PROD_UPLOADS_TTS = "/web/wp-content/uploads/tts" +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" LOCAL_TTS_DIR = Path(__file__).resolve().parent.parent / "wordpress/wp-content/uploads/tts" @@ -85,19 +88,47 @@ 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 sh_quote(s: str) -> str: + return "'" + s.replace("'", "'\\''") + "'" + + +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. + + El wrapping (incluidas redirecciones como `< ruta`) debe quedar DENTRO de la + shell del contenedor (`sh -c '...'`): `docker exec CONTENEDOR cmd < ruta` + resuelve esa redirección en el filesystem del HOST, no en el contenedor. + """ + if PROD_DOCKER_CONTAINER: + return f"docker exec -i {PROD_DOCKER_CONTAINER} sh -c {sh_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) @@ -110,7 +141,7 @@ def prod_helper(subcmd: str, *args: str) -> str: _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) + return _ssh_text(_remote_wrap(inner), timeout=60) def prod_upload_mp3(post_id: int) -> None: @@ -118,14 +149,15 @@ def prod_upload_mp3(post_id: int) -> None: data = src.read_bytes() remote_path = f"{PROD_UPLOADS_TTS}/{post_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}") 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 ─────────────────────────────────────────────────────────────────── diff --git a/scripts/sync_carta_from_prod.py b/scripts/sync_carta_from_prod.py index 6b814e7..236094b 100644 --- a/scripts/sync_carta_from_prod.py +++ b/scripts/sync_carta_from_prod.py @@ -44,7 +44,10 @@ WP_CONTAINER = os.environ.get("FEA_WP_CONTAINER", "wordpress-web") 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", "/web/wp-load.php") +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 se sube y se ejecuta dentro del contenedor. +PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "") PROD_HELPER = "/tmp/fea_translate_helper.php" HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php" @@ -78,18 +81,41 @@ _prod_ready = False def _ssh(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] return sh(cmd, stdin=stdin, timeout=timeout) +def sh_quote(s: str) -> str: + return "'" + s.replace("'", "'\\''") + "'" + + +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. + + El wrapping (incluidas redirecciones como `< ruta`) debe quedar DENTRO de la + shell del contenedor (`sh -c '...'`): `docker exec CONTENEDOR cmd < ruta` + resuelve esa redirección en el filesystem del HOST, no en el contenedor. + """ + if PROD_DOCKER_CONTAINER: + return f"docker exec -i {PROD_DOCKER_CONTAINER} sh -c {sh_quote(inner_cmd)}" + return inner_cmd + + def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str: global _prod_ready if not _prod_ready: - _ssh(f"cat > {PROD_HELPER}", stdin=HELPER_SRC.read_text(encoding="utf-8")) + _ssh(_remote_wrap(f"cat > {PROD_HELPER}"), stdin=HELPER_SRC.read_text(encoding="utf-8")) _prod_ready = True inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args) - return _ssh(inner, stdin=stdin, timeout=180) + return _ssh(_remote_wrap(inner), stdin=stdin, timeout=180) def prod_read_full(post_id: int) -> dict: @@ -234,9 +260,10 @@ def main() -> int: ap.add_argument("--dry-run", action="store_true", help="Solo muestra el plan; no escribe en local.") args = ap.parse_args() - if not PROD_HOST or not PROD_PASS: + if not PROD_HOST: raise SystemExit( - "Faltan FEA_PROD_SSH_HOST / FEA_PROD_SSH_PASS en el entorno.\n" + "Falta FEA_PROD_SSH_HOST en el entorno (FEA_PROD_SSH_PASS es opcional: " + "el Hetzner nuevo usa auth por clave).\n" "Antes de ejecutar: source ~/.hermes/profiles/feadulta/.env" ) diff --git a/scripts/sync_translations_to_prod.py b/scripts/sync_translations_to_prod.py index 82b948b..c38e270 100644 --- a/scripts/sync_translations_to_prod.py +++ b/scripts/sync_translations_to_prod.py @@ -29,9 +29,12 @@ 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_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 se sube y se ejecuta dentro del contenedor. +PROD_DOCKER_CONTAINER = os.environ.get("FEA_PROD_DOCKER_CONTAINER", "") PROD_HELPER = "/tmp/fea_translate_helper.php" HELPER_SRC = Path(__file__).resolve().parent / "fea_translate_helper.php" @@ -159,18 +162,41 @@ _prod_ready = False def _ssh(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] return sh(cmd, stdin=stdin, timeout=timeout) +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. + + El wrapping (incluidas redirecciones como `< ruta`) debe quedar DENTRO de la + shell del contenedor (`sh -c '...'`): `docker exec CONTENEDOR cmd < ruta` + resuelve esa redirección en el filesystem del HOST, no en el contenedor. + """ + if PROD_DOCKER_CONTAINER: + return f"docker exec -i {PROD_DOCKER_CONTAINER} sh -c {sh_quote(inner_cmd)}" + return inner_cmd + + +def sh_quote(s: str) -> str: + return "'" + s.replace("'", "'\\''") + "'" + + def prod_helper(subcmd: str, *args: str, stdin: str | None = None) -> str: global _prod_ready if not _prod_ready: - _ssh(f"cat > {PROD_HELPER}", stdin=HELPER_SRC.read_text(encoding="utf-8")) + _ssh(_remote_wrap(f"cat > {PROD_HELPER}"), stdin=HELPER_SRC.read_text(encoding="utf-8")) _prod_ready = True inner = f"FEA_WP_LOAD={PROD_WPLOAD} php {PROD_HELPER} {subcmd} " + " ".join(args) - return _ssh(inner, stdin=stdin, timeout=180) + return _ssh(_remote_wrap(inner), stdin=stdin, timeout=180) def prod_create(origin: int, lang: str, title: str, content: str) -> int: