feature/generador-instaladores-linux-windows

This commit is contained in:
2026-07-30 07:34:17 -06:00
parent cafe3f1b87
commit c3f1d70e23
34 changed files with 3498 additions and 252 deletions

View File

@@ -12,6 +12,7 @@ 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
@@ -22,6 +23,12 @@ 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."""
@@ -102,12 +109,17 @@ def upload_file_to_folder(local_file: str, cfg: dict, remote_folder: str) -> str
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}")
sftp.put(str(src), 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:
@@ -120,16 +132,45 @@ def upload_file_to_folder(local_file: str, cfg: dict, remote_folder: str) -> str
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:
uploaded.append(upload_file_to_folder(local_path, cfg, remote_folder))
try:
uploaded.append(upload_file_to_folder(local_path, cfg, remote_folder))
except SFTPCopyError as e:
e.uploaded = uploaded
raise
return uploaded