feature/integracion-panel-restore-targets
This commit is contained in:
160
app/transfer/sftp_copy.py
Normal file
160
app/transfer/sftp_copy.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user