feature/integracion-cpanel-asrecovery

This commit is contained in:
2026-06-30 11:29:06 -06:00
parent 072be5b5db
commit 034a5ca5bb
35 changed files with 2416 additions and 320 deletions

36
app/config/__init__.py Normal file
View File

@@ -0,0 +1,36 @@
"""Configuración de entorno, bootstrap y arranque."""
from .bootstrap import ensure_runtime_layout
from .env_loader import (
apply_env_overrides,
get_launch_options,
is_panel_configured,
load_env_file,
)
from .odbc_setup import configure_odbc_environment
from .autostart import register_autostart_if_requested
def initialize_runtime() -> None:
"""Prepara directorios, ODBC y auto-arranque (llamar antes de importar pyodbc)."""
ensure_runtime_layout()
configure_odbc_environment()
def initialize_env() -> None:
"""Post-bootstrap: carga .env y registra auto-arranque si aplica."""
load_env_file()
register_autostart_if_requested()
__all__ = [
"initialize_runtime",
"initialize_env",
"ensure_runtime_layout",
"load_env_file",
"apply_env_overrides",
"get_launch_options",
"is_panel_configured",
"configure_odbc_environment",
"register_autostart_if_requested",
]

88
app/config/autostart.py Normal file
View File

@@ -0,0 +1,88 @@
"""Registro de arranque automático al login (Windows / Linux)."""
import os
import subprocess
import sys
from pathlib import Path
from ..constants import APP_DIR, CONFIG_DIR, IS_WINDOWS
from .env_loader import _env_bool, load_env_file
def _marker_path() -> Path:
return CONFIG_DIR / ".autostart_registered"
def _executable_command() -> tuple[str, list[str]]:
if getattr(sys, "frozen", False):
exe = Path(sys.executable).resolve()
return str(exe), ["--start-engine"]
runner = (APP_DIR / "runner.py").resolve()
return sys.executable, [str(runner), "--start-engine"]
def _register_windows(exe: str, args: list[str]) -> None:
task_name = "CloudRestoreAS"
arg_str = " ".join(f'"{a}"' if " " in a else a for a in args)
tr = f'"{exe}" {arg_str}'
check = subprocess.run(
["schtasks", "/Query", "/TN", task_name],
capture_output=True,
creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0,
)
if check.returncode == 0:
return
subprocess.run(
[
"schtasks",
"/Create",
"/TN",
task_name,
"/TR",
tr,
"/SC",
"ONLOGON",
"/RL",
"LIMITED",
"/F",
],
check=False,
creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0,
)
def _register_linux(exe: str, args: list[str]) -> None:
autostart_dir = Path.home() / ".config" / "autostart"
autostart_dir.mkdir(parents=True, exist_ok=True)
desktop = autostart_dir / "cloudrestoreas.desktop"
if desktop.exists():
return
exec_line = " ".join([exe, *args])
content = f"""[Desktop Entry]
Type=Application
Name=CloudRestoreAS
Comment=Restauración automática SQL Server
Exec={exec_line}
Path={APP_DIR}
Terminal=false
X-GNOME-Autostart-enabled=true
"""
desktop.write_text(content, encoding="utf-8")
def register_autostart_if_requested() -> None:
load_env_file()
if not _env_bool("CLOUDRESTORE_REGISTER_AUTOSTART", True):
return
if _marker_path().exists():
return
exe, args = _executable_command()
try:
if IS_WINDOWS:
_register_windows(exe, args)
else:
_register_linux(exe, args)
_marker_path().write_text("ok", encoding="utf-8")
except OSError:
pass

83
app/config/bootstrap.py Normal file
View File

@@ -0,0 +1,83 @@
"""Creación automática de config/ y carpetas de trabajo."""
import shutil
import sys
from pathlib import Path
from ..constants import (
APP_DIR,
BUNDLE_DIR,
BUNDLED_SOURCE_7ZIP,
BUNDLED_SOURCE_ODBC,
CONFIG_DIR,
DATA_DIR,
DIR_ENTRADA,
DIR_FALLADOS,
DIR_PROCESADOS,
DIR_TEMP,
ENV_PATH,
LOGS_DIR,
ODBC_DIR,
SEVEN_ZIP_DIR,
)
from ..db.database import DatabaseManager
from .env_loader import render_env_template
def _copy_tree_if_missing(src: Path, dest: Path) -> None:
if not src.is_dir() or dest.exists():
return
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(src, dest)
def _write_env_if_missing() -> None:
if ENV_PATH.exists():
return
template_path = BUNDLE_DIR / "packaging" / "templates" / "env.default"
if not template_path.is_file():
template_path = APP_DIR / "packaging" / "templates" / "env.default"
if template_path.is_file():
content = template_path.read_text(encoding="utf-8")
content = content.replace("{APP_DIR}", str(APP_DIR))
else:
content = render_env_template(APP_DIR)
ENV_PATH.write_text(content, encoding="utf-8")
try:
ENV_PATH.chmod(0o600)
except OSError:
pass
def ensure_runtime_layout() -> None:
"""
Idempotente: crea config/, carpetas de trabajo y .env si no existen.
No sobrescribe .env ni app.db existentes.
"""
for directory in (
CONFIG_DIR,
DATA_DIR,
LOGS_DIR,
ODBC_DIR,
SEVEN_ZIP_DIR,
DIR_ENTRADA,
DIR_PROCESADOS,
DIR_FALLADOS,
DIR_TEMP,
):
directory.mkdir(parents=True, exist_ok=True)
_write_env_if_missing()
_copy_tree_if_missing(BUNDLED_SOURCE_7ZIP, SEVEN_ZIP_DIR)
_copy_tree_if_missing(BUNDLED_SOURCE_ODBC, ODBC_DIR)
from ..constants import DB_PATH
if not DB_PATH.exists():
DatabaseManager(DB_PATH)
if getattr(sys, "frozen", False):
marker = CONFIG_DIR / ".bootstrap_ok"
if not marker.exists():
marker.write_text("ok", encoding="utf-8")

138
app/config/env_loader.py Normal file
View File

@@ -0,0 +1,138 @@
"""Carga de variables desde config/.env."""
import os
from dataclasses import dataclass
from pathlib import Path
from dotenv import load_dotenv
from ..constants import (
APP_DIR,
DIR_ENTRADA,
DIR_FALLADOS,
DIR_PROCESADOS,
DIR_TEMP,
ENV_PATH,
default_seven_zip_path,
)
def _env_bool(name: str, default: bool = False) -> bool:
raw = os.getenv(name, "").strip().lower()
if not raw:
return default
return raw in ("1", "true", "yes", "on")
def load_env_file() -> bool:
"""Carga config/.env si existe."""
if ENV_PATH.is_file():
load_dotenv(ENV_PATH, override=True)
return True
return False
def is_panel_configured() -> bool:
"""True si URL, token e instancia del panel están definidos."""
url = os.getenv("CLOUDRESTORE_PANEL_API_URL", "").strip()
token = os.getenv("CLOUDRESTORE_PANEL_API_TOKEN", "").strip()
instance = os.getenv("CLOUDRESTORE_PANEL_INSTANCE_KEY", "").strip()
return bool(url and token and instance)
@dataclass
class LaunchOptions:
minimized: bool
start_engine: bool
register_autostart: bool
def get_launch_options() -> LaunchOptions:
load_env_file()
return LaunchOptions(
minimized=_env_bool("CLOUDRESTORE_START_MINIMIZED", False),
start_engine=_env_bool("CLOUDRESTORE_AUTO_START", True),
register_autostart=_env_bool("CLOUDRESTORE_REGISTER_AUTOSTART", True),
)
def _path_from_env(name: str, default: Path) -> str:
value = os.getenv(name, "").strip()
return value if value else str(default)
def apply_env_overrides(config: dict) -> dict:
"""Aplica variables de entorno sobre la configuración en memoria."""
load_env_file()
paths = config.setdefault("paths", {})
paths["input_folder"] = _path_from_env("CLOUDRESTORE_INPUT_FOLDER", DIR_ENTRADA)
paths["processed_folder"] = _path_from_env(
"CLOUDRESTORE_PROCESSED_FOLDER", DIR_PROCESADOS
)
paths["failed_folder"] = _path_from_env("CLOUDRESTORE_FAILED_FOLDER", DIR_FALLADOS)
paths["extract_folder"] = _path_from_env("CLOUDRESTORE_EXTRACT_FOLDER", DIR_TEMP)
data_sql = os.getenv("CLOUDRESTORE_DATA_SQL_FOLDER", "").strip()
if data_sql:
paths["data_sql_folder"] = data_sql
seven_zip = os.getenv("CLOUDRESTORE_SEVEN_ZIP_EXE", "").strip()
paths["seven_zip_exe"] = seven_zip or default_seven_zip_path()
panel = config.setdefault("panel", {})
api_url = os.getenv("CLOUDRESTORE_PANEL_API_URL", "").strip()
if api_url:
panel["api_url"] = api_url
api_token = os.getenv("CLOUDRESTORE_PANEL_API_TOKEN", "").strip()
if api_token:
panel["api_token"] = api_token
instance = os.getenv("CLOUDRESTORE_PANEL_INSTANCE_KEY", "").strip()
if instance:
panel["instance_key"] = instance
if os.getenv("CLOUDRESTORE_PANEL_VERIFY_SSL"):
panel["verify_ssl"] = _env_bool("CLOUDRESTORE_PANEL_VERIFY_SSL", False)
sql = config.setdefault("sql", {})
server = os.getenv("CLOUDRESTORE_SQL_SERVER", "").strip()
if server:
sql["server"] = server
if os.getenv("CLOUDRESTORE_SQL_USERNAME"):
sql["username"] = os.getenv("CLOUDRESTORE_SQL_USERNAME", "").strip()
if os.getenv("CLOUDRESTORE_SQL_USE_WINDOWS_AUTH"):
sql["use_windows_auth"] = _env_bool("CLOUDRESTORE_SQL_USE_WINDOWS_AUTH", False)
return config
def render_env_template(app_dir: Path | None = None) -> str:
"""Genera contenido inicial de config/.env."""
base = (app_dir or APP_DIR).resolve()
sep = "\\" if os.name == "nt" else "/"
def p(*parts: str) -> str:
return sep.join([str(base), *parts])
return f"""# CloudRestoreAS — configuración local (editar y reiniciar la app)
# Arranca el motor al abrir (la ventana siempre se muestra salvo START_MINIMIZED=true)
CLOUDRESTORE_AUTO_START=true
CLOUDRESTORE_START_MINIMIZED=false
CLOUDRESTORE_REGISTER_AUTOSTART=true
# Servicio PANEL_BASES_ANEXO24 (enrutamiento automático de nodos)
CLOUDRESTORE_PANEL_API_URL=
CLOUDRESTORE_PANEL_API_TOKEN=
CLOUDRESTORE_PANEL_INSTANCE_KEY=
CLOUDRESTORE_PANEL_VERIFY_SSL=false
# Carpetas de trabajo
CLOUDRESTORE_INPUT_FOLDER={p("Entrada")}
CLOUDRESTORE_PROCESSED_FOLDER={p("Procesados")}
CLOUDRESTORE_FAILED_FOLDER={p("Fallados")}
CLOUDRESTORE_EXTRACT_FOLDER={p("Temp")}
# SQL local (solo si no usa panel para credenciales)
# CLOUDRESTORE_SQL_SERVER=localhost
# CLOUDRESTORE_SQL_USERNAME=
# CLOUDRESTORE_DATA_SQL_FOLDER=
"""

71
app/config/odbc_setup.py Normal file
View File

@@ -0,0 +1,71 @@
"""Configura ODBC portable desde config/odbc/."""
import os
import sys
from pathlib import Path
from ..constants import IS_WINDOWS, ODBC_DIR
from ..utils.logger import app_logger
def _write_portable_odbc_ini(odbc_dir: Path) -> None:
"""Genera odbcinst.ini mínimo si no existe."""
inst = odbc_dir / "odbcinst.ini"
if inst.exists():
return
if IS_WINDOWS:
driver_path = odbc_dir / "msodbcsql18.dll"
if not driver_path.exists():
for candidate in odbc_dir.glob("msodbcsql*.dll"):
driver_path = candidate
break
driver_line = str(driver_path.resolve()) if driver_path.exists() else "msodbcsql18.dll"
content = f"""[ODBC Driver 18 for SQL Server]
Description=Microsoft ODBC Driver 18 for SQL Server
Driver={driver_line}
UsageCount=1
"""
else:
so_path = None
for pattern in ("libmsodbcsql-18*.so*", "msodbcsql-18*.so*"):
matches = list(odbc_dir.glob(pattern))
if matches:
so_path = matches[0]
break
driver_line = str(so_path.resolve()) if so_path else "libmsodbcsql-18.so"
content = f"""[ODBC Driver 18 for SQL Server]
Description=Microsoft ODBC Driver 18 for SQL Server
Driver={driver_line}
UsageCount=1
"""
inst.write_text(content, encoding="utf-8")
ini = odbc_dir / "odbc.ini"
if not ini.exists():
ini.write_text("[ODBC Data Sources]\n", encoding="utf-8")
def configure_odbc_environment() -> None:
"""Apunta pyodbc al driver embebido en config/odbc si está disponible."""
if not ODBC_DIR.is_dir():
return
lib_dir = ODBC_DIR / "lib"
if lib_dir.is_dir():
existing = os.environ.get("LD_LIBRARY_PATH", "")
lib_path = str(lib_dir.resolve())
if lib_path not in existing.split(":"):
os.environ["LD_LIBRARY_PATH"] = (
f"{lib_path}:{existing}" if existing else lib_path
)
_write_portable_odbc_ini(ODBC_DIR)
odbc_sys = str(ODBC_DIR.resolve())
os.environ["ODBCSYSINI"] = odbc_sys
os.environ["ODBCINI"] = str((ODBC_DIR / "odbc.ini").resolve())
if IS_WINDOWS:
os.environ["PATH"] = odbc_sys + os.pathsep + os.environ.get("PATH", "")
app_logger.debug(f"ODBC configurado: ODBCSYSINI={odbc_sys}")

View File

@@ -1,13 +1,51 @@
"""Constantes globales de la aplicación."""
import sys
from pathlib import Path
# Rutas base
APP_DIR = Path(__file__).parent.parent
DATA_DIR = APP_DIR / "data"
LOGS_DIR = APP_DIR / "logs"
# Base de datos
def _resolve_app_dir() -> Path:
"""Directorio donde vive el ejecutable (persistente)."""
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent.parent
def _resolve_bundle_dir() -> Path:
"""Recursos embebidos en el binario PyInstaller onefile."""
if getattr(sys, "frozen", False):
return Path(sys._MEIPASS)
platform = "windows" if sys.platform == "win32" else "linux"
return _resolve_app_dir() / "packaging" / "bundled" / platform
APP_DIR = _resolve_app_dir()
CONFIG_DIR = APP_DIR / "config"
ENV_PATH = CONFIG_DIR / ".env"
DATA_DIR = CONFIG_DIR / "data"
LOGS_DIR = CONFIG_DIR / "logs"
ODBC_DIR = CONFIG_DIR / "odbc"
SEVEN_ZIP_DIR = CONFIG_DIR / "7zip"
IS_WINDOWS = sys.platform == "win32"
BUNDLE_DIR = _resolve_bundle_dir()
BUNDLED_SOURCE_7ZIP = BUNDLE_DIR / "bundled" / "7zip"
BUNDLED_SOURCE_ODBC = BUNDLE_DIR / "bundled" / "odbc"
def _default_7zip_name() -> str:
if IS_WINDOWS:
return "7z.exe"
return "7zz"
BUNDLED_7ZIP_EXE = SEVEN_ZIP_DIR / _default_7zip_name()
BUNDLED_7ZIP_FALLBACK = SEVEN_ZIP_DIR / ("7za.exe" if IS_WINDOWS else "7zz")
# Carpetas de trabajo junto al ejecutable
DIR_ENTRADA = APP_DIR / "Entrada"
DIR_PROCESADOS = APP_DIR / "Procesados"
DIR_FALLADOS = APP_DIR / "Fallados"
DIR_TEMP = APP_DIR / "Temp"
DB_PATH = DATA_DIR / "app.db"
# Estados de jobs
@@ -21,7 +59,7 @@ class JobStatus:
FAILED_RESTART = "failed_restart"
CANCELLED = "cancelled"
# Niveles de log
class LogLevel:
DEBUG = "DEBUG"
INFO = "INFO"
@@ -29,7 +67,7 @@ class LogLevel:
ERROR = "ERROR"
CRITICAL = "CRITICAL"
# Tipos de paso de job
class StepType:
STABILITY_CHECK = "stability_check"
NODE_MAPPING = "node_mapping"
@@ -39,57 +77,63 @@ class StepType:
FILELIST = "filelist"
RESTORE = "restore"
CLEANUP = "cleanup"
SFTP_COPY = "sftp_copy" # Transferencia del .bak al servidor SQL externo (SFTP/SSH)
FORWARD_ZIP = "forward_zip" # Reenvío del ZIP al input_folder del servidor destino
SFTP_COPY = "sftp_copy"
FORWARD_ZIP = "forward_zip"
# Configuración por defecto
DEFAULT_CONFIG = {
"paths": {
"input_folder": "",
"processed_folder": "",
"failed_folder": "",
"extract_folder": "",
def default_seven_zip_path() -> str:
for candidate in (BUNDLED_7ZIP_EXE, BUNDLED_7ZIP_FALLBACK):
if candidate.exists():
return str(candidate)
return ""
def default_work_paths() -> dict[str, str]:
return {
"input_folder": str(DIR_ENTRADA),
"processed_folder": str(DIR_PROCESADOS),
"failed_folder": str(DIR_FALLADOS),
"extract_folder": str(DIR_TEMP),
"data_sql_folder": "",
"seven_zip_exe": ""
},
"seven_zip_exe": default_seven_zip_path(),
}
DEFAULT_CONFIG = {
"paths": default_work_paths(),
"sql": {
"server": "localhost",
"use_windows_auth": True,
"use_windows_auth": IS_WINDOWS,
"username": "",
"password_encrypted": ""
"password_encrypted": "",
},
"concurrency": {
"extract_workers": 1,
"restore_workers": 1
"restore_workers": 1,
},
"stability": {
"check_enabled": True,
"check_interval_seconds": 5,
"stable_duration_seconds": 10,
"use_ready_marker": False
"use_ready_marker": False,
},
"timeouts": {
"extract_minutes": 30,
"restore_minutes": 60
"restore_minutes": 60,
},
"retries": {
"max_attempts": 3,
"retry_delay_seconds": 60
"retry_delay_seconds": 60,
},
"features": {
"dry_run_mode": False,
"auto_scan_enabled": True,
"scan_interval_seconds": 30
"scan_interval_seconds": 30,
},
# Integración con PANEL_BASES_ANEXO24. Si api_url está vacío, CloudRestoreAS
# opera en modo local usando la sección "sql" (retrocompatibilidad).
"panel": {
"api_url": "",
"api_token": "",
# Nombre del restore_target en el panel (identidad de este CRA).
# Con panel configurado: enrutamiento automático restore_local | forward.
"instance_key": "",
# False en dev: el panel local usa HTTPS con certificado propio.
"verify_ssl": False,
}
},
}

View File

@@ -12,6 +12,7 @@ from .. import __version__
from ..db.job_repository import JobRepository
from ..db.event_repository import EventRepository
from ..db.config_repository import ConfigRepository
from ..config.env_loader import apply_env_overrides, load_env_file
from ..constants import JobStatus, DEFAULT_CONFIG
from ..panel import panel_client
from ..utils.logger import app_logger
@@ -50,14 +51,15 @@ class RestoreEngine(QObject):
app_logger.info("RestoreEngine inicializado")
def _load_config(self) -> dict:
"""Carga la configuración desde la base de datos."""
"""Carga la configuración desde la base de datos y variables de entorno."""
load_env_file()
config = ConfigRepository.get("app_config", DEFAULT_CONFIG.copy())
# Asegurar que tiene todas las claves
for key, value in DEFAULT_CONFIG.items():
if key not in config:
config[key] = value
config = apply_env_overrides(config)
self.signals.config_loaded.emit(config)
return config

View File

@@ -335,11 +335,17 @@ class RestoreWorker(QRunnable):
stderr=stderr[:1000] if stderr else None
)
# Buscar archivo .bak
# Buscar archivo .bak. El backup puede venir con terminaciones extra
# (p.ej. ".KNOWNWORLD") tanto en el ZIP como en el propio .bak; se
# normaliza a "<nodo>.bak". El nodo es siempre el primer segmento del
# nombre del archivo de origen.
locate_step_id = JobStepRepository.create(self.job_id, StepType.LOCATE_BAK)
self._bak_path = SevenZipExtractor.find_bak_file(self._extract_dir)
node_base = Path(job.source_name).name.split(".")[0].strip() or None
self._bak_path = SevenZipExtractor.find_bak_file(
self._extract_dir, node_name=node_base
)
if not self._bak_path:
raise FileNotFoundError("No se encontró archivo .bak en el archivo extraído")

View File

@@ -1,9 +1,13 @@
"""Gestión de extracción de archivos con 7-Zip."""
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Optional, Tuple
import time
from ..constants import BUNDLED_7ZIP_EXE, BUNDLED_7ZIP_FALLBACK, IS_WINDOWS
from ..utils.logger import app_logger
@@ -25,19 +29,30 @@ class SevenZipExtractor:
@staticmethod
def _auto_detect_7zip() -> Optional[str]:
"""Auto-detecta la ubicación de 7z.exe."""
possible_paths = [
r"C:\Program Files\7-Zip\7z.exe",
r"D:\Program Files\7-Zip\7z.exe",
r"C:\Program Files (x86)\7-Zip\7z.exe",
r"D:\Program Files (x86)\7-Zip\7z.exe"
]
for path in possible_paths:
if Path(path).exists():
app_logger.info(f"7-Zip auto-detectado en: {path}")
return path
"""Prioriza 7-Zip embebido; en desarrollo busca en el sistema."""
for candidate in (BUNDLED_7ZIP_EXE, BUNDLED_7ZIP_FALLBACK):
if candidate.exists():
app_logger.info(f"7-Zip embebido: {candidate}")
return str(candidate)
if not getattr(sys, "frozen", False):
if IS_WINDOWS:
possible_paths = [
r"C:\Program Files\7-Zip\7z.exe",
r"C:\Program Files (x86)\7-Zip\7z.exe",
]
else:
possible_paths = []
for name in ("7z", "7zz", "7za"):
found = shutil.which(name)
if found:
possible_paths.append(found)
for path in possible_paths:
if Path(path).exists():
app_logger.info(f"7-Zip detectado en: {path}")
return path
return None
def extract(
@@ -134,35 +149,78 @@ class SevenZipExtractor:
return result.returncode, result.stdout, result.stderr
@staticmethod
def find_bak_file(extract_dir: str) -> Optional[str]:
def find_bak_file(extract_dir: str, node_name: Optional[str] = None) -> Optional[str]:
"""
Busca el archivo .bak extraído en el directorio.
Busca el backup extraído y, si se indica node_name, lo normaliza a
'<node_name>.bak'.
Tolera terminaciones extra en el nombre (el backup puede venir como
'nodo.KNOWNWORLD.bak', 'nodo.bak.unknownworld' o incluso sin extensión
'.bak'). Estrategia de búsqueda en orden:
1. archivos que terminan exactamente en '.bak'
2. si no hay, archivos cuyo nombre contiene '.bak' en cualquier parte
3. si no hay, el archivo más grande de la extracción (el backup suele serlo)
Args:
extract_dir: Directorio donde se extrajo
node_name: Nombre del nodo; si se indica, el backup se renombra a
'<node_name>.bak'
Returns:
Ruta al archivo .bak o None si no se encuentra
Raises:
ValueError: Si hay múltiples archivos .bak
Ruta al archivo .bak (ya renombrado si node_name) o None si no hay archivos.
"""
extract_path = Path(extract_dir)
bak_files = list(extract_path.rglob("*.bak"))
if not bak_files:
app_logger.error(f"No se encontró archivo .bak en {extract_dir}")
all_files = [p for p in extract_path.rglob("*") if p.is_file()]
if not all_files:
app_logger.error(f"No se encontró ningún archivo en {extract_dir}")
return None
if len(bak_files) > 1:
# Si hay múltiples, tomar el más reciente
bak_files = [p for p in all_files if p.suffix.lower() == ".bak"]
if not bak_files:
bak_files = [p for p in all_files if ".bak" in p.name.lower()]
if not bak_files:
largest = max(all_files, key=lambda p: p.stat().st_size)
app_logger.warning(
f"Múltiples archivos .bak encontrados ({len(bak_files)}), "
"seleccionando el más reciente"
"No se encontró archivo con extensión .bak; usando el archivo más "
f"grande de la extracción: {largest.name}"
)
bak_files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
bak_path = str(bak_files[0])
bak_files = [largest]
if len(bak_files) > 1:
# Preferir el que empiece con el nombre del nodo; si no, el más reciente.
if node_name:
prefixed = [
p for p in bak_files
if p.name.lower().startswith(node_name.lower())
]
if prefixed:
bak_files = prefixed
if len(bak_files) > 1:
app_logger.warning(
f"Múltiples backups encontrados ({len(bak_files)}), "
"seleccionando el más reciente"
)
bak_files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
bak_file = bak_files[0]
# Normalizar el nombre a '<node_name>.bak'.
if node_name:
target = bak_file.parent / f"{node_name}.bak"
if bak_file.name != target.name:
try:
if target.exists() and not bak_file.samefile(target):
target.unlink()
bak_file = bak_file.rename(target)
app_logger.info(f"Backup normalizado a: {bak_file.name}")
except OSError as e:
# No es fatal para el RESTORE: se continúa con el nombre original.
app_logger.error(
f"No se pudo renombrar el backup a '{target.name}': {e}"
)
bak_path = str(bak_file)
app_logger.info(f"Archivo .bak encontrado: {bak_path}")
return bak_path

View File

@@ -4,8 +4,28 @@ import pyodbc
from typing import Optional, Tuple, List
import time
from dataclasses import dataclass
from ..utils.logger import app_logger
_ODBC_DRIVER_CANDIDATES = (
"ODBC Driver 18 for SQL Server",
"ODBC Driver 17 for SQL Server",
"ODBC Driver 13 for SQL Server",
"FreeTDS",
)
def resolve_odbc_driver() -> str:
"""Devuelve el primer driver ODBC de SQL Server disponible."""
installed = set(pyodbc.drivers())
for name in _ODBC_DRIVER_CANDIDATES:
if name in installed:
return name
raise RuntimeError(
"No se encontró driver ODBC para SQL Server. "
f"Instalados: {', '.join(pyodbc.drivers()) or 'ninguno'}"
)
@dataclass
class LogicalFile:
@@ -50,10 +70,13 @@ class SQLServerManager:
Returns:
Cadena de conexión ODBC
"""
driver = resolve_odbc_driver()
parts = [
f"DRIVER={{ODBC Driver 17 for SQL Server}}",
f"SERVER={self.server}"
f"DRIVER={{{driver}}}",
f"SERVER={self.server}",
]
if "18" in driver:
parts.append("TrustServerCertificate=yes")
if database:
parts.append(f"DATABASE={database}")

View File

@@ -1,5 +1,7 @@
"""Tab de configuración."""
import sys
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox,
QFormLayout, QLineEdit, QPushButton, QSpinBox,
@@ -104,6 +106,12 @@ class ConfigTab(QWidget):
self.sql_windows_auth_checkbox.setChecked(True)
self.sql_windows_auth_checkbox.toggled.connect(self._on_auth_changed)
sql_layout.addRow("Usar Windows Auth:", self.sql_windows_auth_checkbox)
if sys.platform != "win32":
self.sql_windows_auth_checkbox.setChecked(False)
self.sql_windows_auth_checkbox.setEnabled(False)
self.sql_windows_auth_checkbox.setToolTip(
"Windows Auth no está disponible en Linux; use SQL Auth."
)
self.sql_username_input = QLineEdit()
self.sql_username_input.setEnabled(False)

View File

@@ -32,6 +32,14 @@ class DashboardTab(QWidget):
title_font.setBold(True)
title.setFont(title_font)
layout.addWidget(title)
self.panel_warning_label = QLabel("")
self.panel_warning_label.setWordWrap(True)
self.panel_warning_label.setStyleSheet(
"background-color: #fff3cd; color: #856404; padding: 8px; border-radius: 4px;"
)
self.panel_warning_label.hide()
layout.addWidget(self.panel_warning_label)
# Grid de estadísticas
stats_group = QGroupBox("Estadísticas de Jobs")
@@ -94,17 +102,29 @@ class DashboardTab(QWidget):
times_group.setLayout(times_layout)
layout.addWidget(times_group)
self.engine_status_badge = QLabel("Detenido")
badge_font = QFont()
badge_font.setPointSize(12)
badge_font.setBold(True)
self.engine_status_badge.setFont(badge_font)
self.engine_status_badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.engine_status_badge.setStyleSheet(
"background-color: #e9ecef; color: #495057; padding: 6px 14px; "
"border-radius: 4px;"
)
layout.addWidget(self.engine_status_badge)
# Estado del motor
motor_group = QGroupBox("Estado del Motor")
motor_layout = QHBoxLayout()
self.motor_status_label = QLabel("Detenido")
motor_status_font = QFont()
motor_status_font.setPointSize(14)
motor_status_font.setBold(True)
self.motor_status_label.setFont(motor_status_font)
self.motor_status_label.setStyleSheet("color: red;")
motor_layout.addWidget(QLabel("Estado:"))
motor_layout.addWidget(self.motor_status_label)
motor_layout.addStretch()
@@ -136,6 +156,7 @@ class DashboardTab(QWidget):
def _load_initial_stats(self):
"""Carga las estadísticas iniciales."""
self.update_motor_status()
stats = self.engine.get_stats()
self.update_stats(stats)
self._refresh_recent_jobs()
@@ -193,3 +214,40 @@ class DashboardTab(QWidget):
self.recent_table.setItem(i, 4, status_item)
self.recent_table.resizeColumnsToContents()
def update_motor_status(self) -> None:
"""Actualiza badge y etiqueta según el estado actual del motor."""
if not self.engine.is_running():
text = "Detenido"
badge_style = (
"background-color: #e9ecef; color: #495057; padding: 6px 14px; "
"border-radius: 4px;"
)
label_style = "color: red;"
elif self.engine.is_paused():
text = "Pausado"
badge_style = (
"background-color: #fff3cd; color: #856404; padding: 6px 14px; "
"border-radius: 4px;"
)
label_style = "color: orange;"
else:
text = "Activo"
badge_style = (
"background-color: #d4edda; color: #155724; padding: 6px 14px; "
"border-radius: 4px;"
)
label_style = "color: green;"
self.engine_status_badge.setText(f"Motor: {text}")
self.engine_status_badge.setStyleSheet(badge_style)
self.motor_status_label.setText(text)
self.motor_status_label.setStyleSheet(label_style)
def set_panel_warning(self, message: str) -> None:
"""Muestra aviso cuando falta configuración del servicio de bases en config/.env."""
if message:
self.panel_warning_label.setText(message)
self.panel_warning_label.show()
else:
self.panel_warning_label.hide()

View File

@@ -2,181 +2,207 @@
from PySide6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QTabWidget,
QSystemTrayIcon, QMenu
QSystemTrayIcon, QMenu, QMessageBox, QApplication,
)
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtGui import QIcon, QCloseEvent, QAction
from PySide6.QtGui import QCloseEvent, QAction
from .dashboard_tab import DashboardTab
from .jobs_tab import JobsTab
from .nodes_tab import NodesTab
from .config_tab import ConfigTab
from .logs_tab import LogsTab
from .tray_assets import load_tray_icon
from ..engine.engine import RestoreEngine
from ..utils.logger import app_logger
class MainWindow(QMainWindow):
"""Ventana principal de la aplicación."""
# Señal para minimizar a tray
minimize_to_tray = Signal()
def __init__(self):
def __init__(
self,
minimized: bool = False,
start_engine: bool = False,
panel_configured: bool = True,
):
"""Inicializa la ventana principal."""
super().__init__()
# Motor de restauración
self._start_on_load = start_engine
self._minimized_on_load = minimized
self._panel_configured = panel_configured
self._force_quit = False
self._tray_hint_shown = False
self.engine = RestoreEngine()
# Configurar ventana
self.setWindowTitle("CloudRestoreAS - Restauración Automática SQL Server")
self.setMinimumSize(1200, 800)
# System tray
self.tray_icon: QSystemTrayIcon = None
self.tray_icon: QSystemTrayIcon | None = None
self._setup_tray()
# UI
self._setup_ui()
# Conectar señales del motor
self._connect_engine_signals()
# Timer para actualizar stats
self.stats_timer = QTimer()
self.stats_timer.timeout.connect(self._update_stats)
self.stats_timer.start(5000) # Cada 5 segundos
self.stats_timer.start(5000)
if not self._panel_configured:
self.dashboard_tab.set_panel_warning(
"Configure la conexión al servicio de bases en config/.env "
"(CLOUDRESTORE_PANEL_API_URL, TOKEN e INSTANCE_KEY)."
)
if self._start_on_load:
QTimer.singleShot(500, self._start_engine)
if self._minimized_on_load:
QTimer.singleShot(100, self.hide)
self._update_tray_status()
self.dashboard_tab.update_motor_status()
app_logger.info("Ventana principal inicializada")
def _setup_ui(self):
"""Configura la interfaz de usuario."""
# Widget central
central_widget = QWidget()
self.setCentralWidget(central_widget)
# Layout principal
layout = QVBoxLayout(central_widget)
layout.setContentsMargins(10, 10, 10, 10)
# Tabs
self.tabs = QTabWidget()
# Crear tabs
self.dashboard_tab = DashboardTab(self.engine)
self.jobs_tab = JobsTab(self.engine)
self.nodes_tab = NodesTab(self.engine)
self.config_tab = ConfigTab(self.engine)
self.logs_tab = LogsTab(self.engine)
# Agregar tabs
self.tabs.addTab(self.dashboard_tab, "📊 Dashboard")
self.tabs.addTab(self.jobs_tab, "📋 Jobs")
self.tabs.addTab(self.nodes_tab, "🔗 Nodos")
self.tabs.addTab(self.config_tab, "⚙️ Configuración")
self.tabs.addTab(self.logs_tab, "📝 Logs")
layout.addWidget(self.tabs)
# Menu bar
self._setup_menu()
def _setup_menu(self):
"""Configura el menú."""
menubar = self.menuBar()
# Menú Motor
file_menu = menubar.addMenu("Archivo")
quit_action = QAction("Salir", self)
quit_action.triggered.connect(self._quit_application)
file_menu.addAction(quit_action)
motor_menu = menubar.addMenu("Motor")
self.start_action = QAction("▶️ Iniciar", self)
self.start_action.triggered.connect(self._start_engine)
motor_menu.addAction(self.start_action)
self.pause_action = QAction("⏸️ Pausar", self)
self.pause_action.triggered.connect(self._pause_engine)
self.pause_action.setEnabled(False)
motor_menu.addAction(self.pause_action)
self.resume_action = QAction("▶️ Continuar", self)
self.resume_action.triggered.connect(self._resume_engine)
self.resume_action.setEnabled(False)
motor_menu.addAction(self.resume_action)
self.stop_action = QAction("⏹️ Detener", self)
self.stop_action.triggered.connect(self._stop_engine)
self.stop_action.setEnabled(False)
motor_menu.addAction(self.stop_action)
motor_menu.addSeparator()
self.scan_action = QAction("🔍 Escanear Ahora", self)
self.scan_action.triggered.connect(self._scan_now)
motor_menu.addAction(self.scan_action)
# Menú Ayuda
help_menu = menubar.addMenu("Ayuda")
about_action = QAction("Acerca de", self)
about_action.triggered.connect(self._show_about)
help_menu.addAction(about_action)
def _setup_tray(self):
"""Configura el icono de bandeja del sistema."""
# Crear icono (usar un icono por defecto o crear uno simple)
if not QSystemTrayIcon.isSystemTrayAvailable():
app_logger.warning("Bandeja del sistema no disponible en este entorno")
return
self.tray_icon = QSystemTrayIcon(self)
self.tray_icon.setIcon(load_tray_icon())
self.tray_icon.setToolTip("CloudRestoreAS")
# Menú del tray
tray_menu = QMenu()
show_action = QAction("Mostrar", self)
show_action.triggered.connect(self._show_window)
tray_menu.addAction(show_action)
hide_action = QAction("Ocultar", self)
hide_action.triggered.connect(self.hide)
tray_menu.addAction(hide_action)
tray_menu.addSeparator()
pause_tray_action = QAction("Pausar", self)
pause_tray_action.triggered.connect(self._pause_engine)
tray_menu.addAction(pause_tray_action)
resume_tray_action = QAction("Continuar", self)
resume_tray_action.triggered.connect(self._resume_engine)
tray_menu.addAction(resume_tray_action)
tray_menu.addSeparator()
quit_action = QAction("Salir", self)
quit_action.triggered.connect(self._quit_application)
tray_menu.addAction(quit_action)
quit_tray_action = QAction("Salir", self)
quit_tray_action.triggered.connect(self._quit_application)
tray_menu.addAction(quit_tray_action)
self.tray_icon.setContextMenu(tray_menu)
# Doble clic en tray para mostrar/ocultar
self.tray_icon.activated.connect(self._on_tray_activated)
# Mostrar tray
self.tray_icon.show()
def _connect_engine_signals(self):
"""Conecta las señales del motor."""
self.engine.signals.job_created.connect(self._on_job_created)
self.engine.signals.stats_updated.connect(self._on_stats_updated)
self.engine.signals.config_loaded.connect(self._on_config_loaded)
def closeEvent(self, event: QCloseEvent):
"""Maneja el evento de cierre de ventana (minimizar a tray)."""
if self.tray_icon.isVisible():
"""Minimiza a bandeja al cerrar la ventana (no sale de la aplicación)."""
if self._force_quit:
event.accept()
return
if self.tray_icon and self.tray_icon.isVisible():
self.hide()
event.ignore()
app_logger.info("Ventana minimizada a bandeja del sistema")
if not self._tray_hint_shown:
self.tray_icon.showMessage(
"CloudRestoreAS sigue en ejecución",
"La aplicación está en la bandeja. Use clic derecho → Salir para cerrar.",
QSystemTrayIcon.MessageIcon.Information,
4000,
)
self._tray_hint_shown = True
else:
event.accept()
def _on_tray_activated(self, reason):
"""Maneja la activación del icono de tray."""
if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
@@ -184,35 +210,56 @@ class MainWindow(QMainWindow):
self.hide()
else:
self._show_window()
def _show_window(self):
"""Muestra la ventana."""
self.show()
self.activateWindow()
self.raise_()
def _motor_status_text(self) -> str:
if not self.engine.is_running():
return "Detenido"
if self.engine.is_paused():
return "Pausado"
return "Activo"
def _update_tray_status(self):
"""Actualiza tooltip del icono de bandeja según estado del motor."""
if not self.tray_icon:
return
status = self._motor_status_text()
self.tray_icon.setToolTip(f"CloudRestoreAS — Motor: {status}")
def _start_engine(self):
"""Inicia el motor."""
self.engine.start()
self.start_action.setEnabled(False)
self.pause_action.setEnabled(True)
self.stop_action.setEnabled(True)
self.resume_action.setEnabled(False)
self._update_tray_status()
self.dashboard_tab.update_motor_status()
app_logger.info("Motor iniciado desde UI")
def _pause_engine(self):
"""Pausa el motor."""
self.engine.pause()
self.pause_action.setEnabled(False)
self.resume_action.setEnabled(True)
self._update_tray_status()
self.dashboard_tab.update_motor_status()
app_logger.info("Motor pausado desde UI")
def _resume_engine(self):
"""Reanuda el motor."""
self.engine.resume()
self.resume_action.setEnabled(False)
self.pause_action.setEnabled(True)
self._update_tray_status()
self.dashboard_tab.update_motor_status()
app_logger.info("Motor reanudado desde UI")
def _stop_engine(self):
"""Detiene el motor."""
self.engine.stop()
@@ -220,48 +267,53 @@ class MainWindow(QMainWindow):
self.pause_action.setEnabled(False)
self.resume_action.setEnabled(False)
self.start_action.setEnabled(True)
self._update_tray_status()
self.dashboard_tab.update_motor_status()
app_logger.info("Motor detenido desde UI")
def _scan_now(self):
"""Fuerza un escaneo manual."""
self.engine.scan_now()
app_logger.info("Escaneo manual solicitado desde UI")
def _update_stats(self):
"""Actualiza las estadísticas."""
self._update_tray_status()
self.dashboard_tab.update_motor_status()
if self.engine.is_running():
stats = self.engine.get_stats()
self.dashboard_tab.update_stats(stats)
def _on_job_created(self, job_id: str):
"""Maneja la creación de un nuevo job."""
self.jobs_tab.refresh_jobs()
self._update_stats()
def _on_stats_updated(self, stats: dict):
"""Maneja la actualización de estadísticas."""
self.dashboard_tab.update_stats(stats)
def _on_config_loaded(self, config: dict):
"""Maneja la carga de configuración."""
self.config_tab.load_config(config)
def _show_about(self):
"""Muestra el diálogo Acerca de."""
from PySide6.QtWidgets import QMessageBox
QMessageBox.about(
self,
"Acerca de CloudRestoreAS",
"CloudRestoreAS v1.0.0\n\n"
"Aplicación de restauración automática de bases de datos SQL Server.\n\n"
"© 2026 Aduanasoft"
"Al cerrar la ventana, la aplicación permanece en la bandeja del sistema.\n"
"Use Archivo → Salir o la bandeja → Salir para cerrar por completo.\n\n"
"© 2026 Aduanasoft",
)
def _quit_application(self):
"""Sale de la aplicación completamente."""
app_logger.info("Saliendo de la aplicación")
self._force_quit = True
self.engine.stop()
self.tray_icon.hide()
self.close()
from PySide6.QtWidgets import QApplication
if self.tray_icon:
self.tray_icon.hide()
QApplication.quit()

55
app/ui/tray_assets.py Normal file
View File

@@ -0,0 +1,55 @@
"""Icono de bandeja del sistema."""
import sys
from pathlib import Path
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QIcon, QPainter, QPixmap
_ASSETS_DIR = Path(__file__).resolve().parent.parent.parent / "packaging" / "assets"
def _generated_tray_icon() -> QIcon:
"""Icono simple en memoria si no hay archivo empaquetado."""
size = 32
pixmap = QPixmap(size, size)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setBrush(QColor(0, 120, 215))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawRoundedRect(2, 2, size - 4, size - 4, 6, 6)
painter.setPen(QColor(255, 255, 255))
font = painter.font()
font.setBold(True)
font.setPointSize(14)
painter.setFont(font)
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "C")
painter.end()
return QIcon(pixmap)
def load_tray_icon() -> QIcon:
"""Carga icono de bandeja desde assets empaquetados o genera uno por defecto."""
candidates: list[Path] = []
meipass = getattr(sys, "_MEIPASS", None)
if meipass:
base = Path(meipass)
candidates.extend(
[
base / "packaging" / "assets" / "tray-icon.png",
base / "packaging" / "assets" / "tray-icon.ico",
]
)
candidates.extend(
[
_ASSETS_DIR / "tray-icon.png",
_ASSETS_DIR / "tray-icon.ico",
]
)
for path in candidates:
if path.is_file():
icon = QIcon(str(path))
if not icon.isNull():
return icon
return _generated_tray_icon()

View File

@@ -1,59 +1,68 @@
"""Utilidades de cifrado para passwords usando DPAPI en Windows."""
"""Utilidades de cifrado para passwords (DPAPI en Windows, Fernet en Linux)."""
import base64
import win32crypt
import sys
from pathlib import Path
from typing import Optional
from ..constants import DATA_DIR
_IS_WINDOWS = sys.platform == "win32"
_SECRET_FILE = DATA_DIR / ".secret"
def _get_fernet():
from cryptography.fernet import Fernet
DATA_DIR.mkdir(parents=True, exist_ok=True)
if not _SECRET_FILE.exists():
key = Fernet.generate_key()
_SECRET_FILE.write_bytes(key)
try:
_SECRET_FILE.chmod(0o600)
except OSError:
pass
else:
key = _SECRET_FILE.read_bytes()
return Fernet(key)
def encrypt_password(password: str) -> str:
"""
Cifra una contraseña usando DPAPI de Windows.
Args:
password: Contraseña en texto plano
Returns:
Contraseña cifrada en base64
"""
"""Cifra una contraseña."""
if not password:
return ""
try:
encrypted_bytes = win32crypt.CryptProtectData(
password.encode('utf-8'),
None,
None,
None,
None,
0
)
return base64.b64encode(encrypted_bytes).decode('ascii')
if _IS_WINDOWS:
import win32crypt
encrypted_bytes = win32crypt.CryptProtectData(
password.encode("utf-8"), None, None, None, None, 0
)
return base64.b64encode(encrypted_bytes).decode("ascii")
fernet = _get_fernet()
return fernet.encrypt(password.encode("utf-8")).decode("ascii")
except Exception as e:
raise RuntimeError(f"Error cifrando contraseña: {e}")
raise RuntimeError(f"Error cifrando contraseña: {e}") from e
def decrypt_password(encrypted_password: str) -> str:
"""
Descifra una contraseña usando DPAPI de Windows.
Args:
encrypted_password: Contraseña cifrada en base64
Returns:
Contraseña en texto plano
"""
"""Descifra una contraseña."""
if not encrypted_password:
return ""
try:
encrypted_bytes = base64.b64decode(encrypted_password)
decrypted_bytes = win32crypt.CryptUnprotectData(
encrypted_bytes,
None,
None,
None,
0
)[1]
return decrypted_bytes.decode('utf-8')
if _IS_WINDOWS:
import win32crypt
encrypted_bytes = base64.b64decode(encrypted_password)
decrypted_bytes = win32crypt.CryptUnprotectData(
encrypted_bytes, None, None, None, 0
)[1]
return decrypted_bytes.decode("utf-8")
fernet = _get_fernet()
return fernet.decrypt(encrypted_password.encode("ascii")).decode("utf-8")
except Exception as e:
raise RuntimeError(f"Error descifrando contraseña: {e}")
raise RuntimeError(f"Error descifrando contraseña: {e}") from e