202 lines
7.1 KiB
Python
202 lines
7.1 KiB
Python
"""
|
|
Transferencia del .bak al servidor SQL externo vía SFTP/SSH (paramiko).
|
|
|
|
Los servidores SQL destino son máquinas externas independientes (no comparten red
|
|
local con CloudRestoreAS), por lo que el .bak se sube por SFTP al servidor y el SQL
|
|
Server restaura desde su disco local (remote_inbox_path). Tras el job, el .bak del
|
|
servidor se elimina siempre (G10).
|
|
|
|
Nota de seguridad: se usa AutoAddPolicy para las host keys (TOFU). Para endurecer en
|
|
producción conviene fijar/known_hosts las claves de cada servidor.
|
|
"""
|
|
|
|
import ntpath
|
|
import posixpath
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import paramiko
|
|
|
|
from ..utils.logger import app_logger
|
|
|
|
# Timeout de conexión SSH en segundos.
|
|
SSH_TIMEOUT = 30
|
|
|
|
# Verificación de la subida: tras escribir los bytes se confirma el tamaño remoto con
|
|
# reintentos. Tolera stats flaky (buffering/AV/locking, típico en OpenSSH/Windows) que de
|
|
# otro modo producirían un FALSO FALLO aunque el archivo sí se entregó.
|
|
VERIFY_ATTEMPTS = 3
|
|
VERIFY_DELAY_SECONDS = 1.0
|
|
|
|
|
|
class SFTPCopyError(Exception):
|
|
"""Error al transferir o limpiar el .bak en el servidor remoto vía SFTP."""
|
|
|
|
|
|
def _sftp_path(remote_inbox_path: str, filename: str) -> str:
|
|
"""
|
|
Ruta estilo POSIX para SFTP a partir de la carpeta destino (que puede venir en
|
|
formato Windows, p. ej. C:\\RestoreInbox). OpenSSH en Windows acepta 'C:/...'.
|
|
"""
|
|
posix_dir = remote_inbox_path.replace("\\", "/").rstrip("/")
|
|
return f"{posix_dir}/{filename}"
|
|
|
|
|
|
def windows_restore_path(remote_inbox_path: str, filename: str) -> str:
|
|
"""Ruta Windows que usará RESTORE DATABASE en el servidor (C:\\RestoreInbox\\x.bak)."""
|
|
return ntpath.join(remote_inbox_path, filename)
|
|
|
|
|
|
def _connect(cfg: dict) -> paramiko.SSHClient:
|
|
"""Abre una conexión SSH con las credenciales del servidor (cfg del PANEL)."""
|
|
client = paramiko.SSHClient()
|
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
try:
|
|
client.connect(
|
|
hostname=cfg["ssh_host"],
|
|
port=int(cfg.get("ssh_port") or 22),
|
|
username=cfg["ssh_username"],
|
|
password=cfg["ssh_password"],
|
|
timeout=SSH_TIMEOUT,
|
|
allow_agent=False,
|
|
look_for_keys=False,
|
|
)
|
|
except Exception as e:
|
|
raise SFTPCopyError(
|
|
f"No se pudo conectar por SSH a {cfg.get('ssh_host')}:{cfg.get('ssh_port')}: {e}"
|
|
) from e
|
|
return client
|
|
|
|
|
|
def upload_to_remote(local_bak: str, cfg: dict) -> str:
|
|
"""
|
|
Sube `local_bak` al servidor remoto vía SFTP, a remote_inbox_path/filename.
|
|
|
|
Args:
|
|
local_bak: ruta local del .bak ya extraído.
|
|
cfg: dict con ssh_host, ssh_port, ssh_username, ssh_password, remote_inbox_path.
|
|
|
|
Returns:
|
|
Ruta SFTP (POSIX) del .bak en el servidor, para limpieza posterior.
|
|
|
|
Raises:
|
|
SFTPCopyError: si el origen no existe o falla la conexión/transferencia.
|
|
"""
|
|
remote_folder = cfg.get("remote_inbox_path") or ""
|
|
return upload_file_to_folder(local_bak, cfg, remote_folder)
|
|
|
|
|
|
def upload_file_to_folder(local_file: str, cfg: dict, remote_folder: str) -> str:
|
|
"""
|
|
Sube un archivo local a remote_folder/filename vía SFTP.
|
|
|
|
Args:
|
|
local_file: ruta local del archivo.
|
|
cfg: dict con ssh_host, ssh_port, ssh_username, ssh_password.
|
|
remote_folder: carpeta destino en el servidor remoto (Windows o POSIX).
|
|
|
|
Returns:
|
|
Ruta SFTP (POSIX) del archivo en el servidor.
|
|
|
|
Raises:
|
|
SFTPCopyError: si el origen no existe o falla la transferencia.
|
|
"""
|
|
src = Path(local_file)
|
|
if not src.is_file():
|
|
raise SFTPCopyError(f"El archivo local no existe: {local_file}")
|
|
if not remote_folder or not str(remote_folder).strip():
|
|
raise SFTPCopyError("La carpeta remota destino está vacía")
|
|
|
|
remote_sftp = _sftp_path(str(remote_folder).strip(), src.name)
|
|
expected_size = src.stat().st_size
|
|
client = _connect(cfg)
|
|
try:
|
|
sftp = client.open_sftp()
|
|
try:
|
|
app_logger.info(f"Subiendo archivo por SFTP a {cfg['ssh_host']}: {remote_sftp}")
|
|
# confirm=False: la verificación de paramiko hace un stat inmediato que en
|
|
# OpenSSH/Windows suele fallar por buffering aunque el archivo sí se escribió.
|
|
# Se verifica el tamaño aparte, con reintentos (ver _verify_remote_size).
|
|
sftp.put(str(src), remote_sftp, confirm=False)
|
|
_verify_remote_size(sftp, remote_sftp, expected_size)
|
|
finally:
|
|
sftp.close()
|
|
except SFTPCopyError:
|
|
raise
|
|
except Exception as e:
|
|
raise SFTPCopyError(f"Fallo al subir archivo por SFTP ({remote_sftp}): {e}") from e
|
|
finally:
|
|
client.close()
|
|
|
|
return remote_sftp
|
|
|
|
|
|
def _verify_remote_size(sftp, remote_path: str, expected_size: int) -> None:
|
|
"""Confirma que el archivo remoto tiene el tamaño esperado, con reintentos.
|
|
|
|
Un stat transitoriamente fallido o con tamaño aún incompleto (flush/AV en curso) se
|
|
reintenta; solo tras agotar los intentos se considera fallo genuino de entrega.
|
|
"""
|
|
last_error: Optional[str] = None
|
|
for attempt in range(VERIFY_ATTEMPTS):
|
|
try:
|
|
remote_size = sftp.stat(remote_path).st_size
|
|
if remote_size == expected_size:
|
|
return
|
|
last_error = f"tamaño remoto {remote_size} != esperado {expected_size}"
|
|
except Exception as e: # noqa: BLE001 — se reintenta y, si persiste, se eleva abajo
|
|
last_error = str(e)
|
|
if attempt < VERIFY_ATTEMPTS - 1:
|
|
time.sleep(VERIFY_DELAY_SECONDS)
|
|
raise SFTPCopyError(
|
|
f"No se pudo verificar la subida de {remote_path}: {last_error}"
|
|
)
|
|
|
|
|
|
def upload_zip_parts(local_paths: list[str], cfg: dict, remote_folder: str) -> list[str]:
|
|
"""
|
|
Sube uno o más archivos ZIP (incl. multipart) al input_folder del destino.
|
|
|
|
Ante un fallo, adjunta a la excepción la lista de partes ya subidas (atributo
|
|
``uploaded``) para que el llamador pueda limpiar el envío parcial en el destino.
|
|
|
|
Returns:
|
|
Lista de rutas SFTP subidas.
|
|
"""
|
|
uploaded: list[str] = []
|
|
for local_path in local_paths:
|
|
try:
|
|
uploaded.append(upload_file_to_folder(local_path, cfg, remote_folder))
|
|
except SFTPCopyError as e:
|
|
e.uploaded = uploaded
|
|
raise
|
|
return uploaded
|
|
|
|
|
|
def cleanup_remote(cfg: dict, remote_sftp_path: Optional[str]) -> None:
|
|
"""
|
|
Elimina el .bak del servidor remoto vía SFTP. Best-effort: registra pero no lanza,
|
|
para no enmascarar el resultado real del job. Se invoca siempre en `finally`.
|
|
"""
|
|
if not remote_sftp_path:
|
|
return
|
|
try:
|
|
client = _connect(cfg)
|
|
except SFTPCopyError as e:
|
|
app_logger.error(f"No se pudo conectar para limpiar el .bak remoto: {e}")
|
|
return
|
|
try:
|
|
sftp = client.open_sftp()
|
|
try:
|
|
sftp.remove(remote_sftp_path)
|
|
app_logger.info(f"Inbox remoto limpiado: {remote_sftp_path}")
|
|
except FileNotFoundError:
|
|
pass
|
|
finally:
|
|
sftp.close()
|
|
except Exception as e:
|
|
app_logger.error(f"No se pudo limpiar el .bak remoto ({remote_sftp_path}): {e}")
|
|
finally:
|
|
client.close()
|