161 lines
5.2 KiB
Python
161 lines
5.2 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
|
|
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
|
|
|
|
|
|
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)
|
|
client = _connect(cfg)
|
|
try:
|
|
sftp = client.open_sftp()
|
|
try:
|
|
app_logger.info(f"Subiendo archivo por SFTP a {cfg['ssh_host']}: {remote_sftp}")
|
|
sftp.put(str(src), remote_sftp)
|
|
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 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.
|
|
|
|
Returns:
|
|
Lista de rutas SFTP subidas.
|
|
"""
|
|
uploaded: list[str] = []
|
|
for local_path in local_paths:
|
|
uploaded.append(upload_file_to_folder(local_path, cfg, remote_folder))
|
|
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()
|