"""Gestión de conexiones y operaciones SQL Server.""" import pyodbc from typing import Optional, Tuple, List import time from dataclasses import dataclass from ..utils.logger import app_logger @dataclass class LogicalFile: """Representa un archivo lógico de una base de datos.""" logical_name: str physical_name: str type: str # 'D' (data) o 'L' (log) class SQLServerManager: """Gestor de operaciones con SQL Server.""" def __init__( self, server: str, use_windows_auth: bool = True, username: Optional[str] = None, password: Optional[str] = None ): """ Inicializa el gestor de SQL Server. Args: server: Nombre o IP del servidor SQL Server use_windows_auth: Usar autenticación de Windows username: Usuario SQL (si no usa Windows Auth) password: Contraseña SQL (si no usa Windows Auth) """ # ODBC usa coma para el puerto (ip,puerto), no dos puntos self.server = server.replace(":", ",") if ":" in server else server self.use_windows_auth = use_windows_auth self.username = username self.password = password def get_connection_string(self, database: Optional[str] = None) -> str: """ Construye la cadena de conexión. Args: database: Base de datos específica (opcional) Returns: Cadena de conexión ODBC """ parts = [ f"DRIVER={{ODBC Driver 17 for SQL Server}}", f"SERVER={self.server}" ] if database: parts.append(f"DATABASE={database}") if self.use_windows_auth: parts.append("Trusted_Connection=yes") else: parts.append(f"UID={self.username}") parts.append(f"PWD={self.password}") return ";".join(parts) def test_connection(self) -> Tuple[bool, Optional[str]]: """ Prueba la conexión a SQL Server. Returns: Tupla (éxito, mensaje_error) """ try: conn_str = self.get_connection_string() conn = pyodbc.connect(conn_str, timeout=10) cursor = conn.cursor() cursor.execute("SELECT @@VERSION") version = cursor.fetchone()[0] conn.close() app_logger.info(f"Conexión SQL Server exitosa: {version[:50]}...") return True, None except Exception as e: error_msg = f"Error conectando a SQL Server: {str(e)}" app_logger.error(error_msg) return False, error_msg def get_filelist_from_backup( self, backup_path: str, timeout_minutes: int = 5 ) -> Tuple[List[LogicalFile], Optional[str]]: """ Obtiene la lista de archivos lógicos de un backup usando RESTORE FILELISTONLY. Args: backup_path: Ruta al archivo .bak timeout_minutes: Timeout en minutos Returns: Tupla (lista de LogicalFile, mensaje_error) """ try: conn_str = self.get_connection_string() conn = pyodbc.connect(conn_str, timeout=timeout_minutes * 60) cursor = conn.cursor() start_time = time.time() app_logger.info(f"Ejecutando RESTORE FILELISTONLY FROM DISK = '{backup_path}'") query = f"RESTORE FILELISTONLY FROM DISK = N'{backup_path}'" cursor.execute(query) elapsed = time.time() - start_time app_logger.info(f"FILELISTONLY completado en {elapsed:.2f}s") logical_files = [] for row in cursor.fetchall(): # Las columnas típicas son: LogicalName, PhysicalName, Type, ... logical_name = row.LogicalName physical_name = row.PhysicalName file_type = row.Type logical_files.append(LogicalFile( logical_name=logical_name, physical_name=physical_name, type=file_type )) app_logger.debug( f"Archivo lógico: {logical_name} ({file_type}) -> {physical_name}" ) conn.close() if not logical_files: return [], "No se encontraron archivos lógicos en el backup" return logical_files, None except Exception as e: error_msg = f"Error obteniendo FILELISTONLY: {str(e)}" app_logger.error(error_msg) return [], error_msg def restore_database( self, db_name: str, backup_path: str, data_folder: str, logical_files: List[LogicalFile], timeout_minutes: int = 60, dry_run: bool = False ) -> Tuple[bool, Optional[str], Optional[str]]: """ Restaura una base de datos desde un backup. Args: db_name: Nombre de la base de datos backup_path: Ruta al archivo .bak data_folder: Carpeta donde se crearán los archivos de datos logical_files: Lista de archivos lógicos del backup timeout_minutes: Timeout en minutos dry_run: Si True, solo simula (no ejecuta RESTORE) Returns: Tupla (éxito, stdout, error) """ conn = None try: # Construir las cláusulas MOVE con un destino único por archivo lógico. # Renombrar todos los data files a {db}.mdf colisiona si el backup tiene # varios archivos; aquí cada archivo recibe un nombre distinto (G4). move_clauses = self._build_move_clauses(db_name, data_folder, logical_files) if not move_clauses: return False, None, "No se pudieron determinar los archivos del backup" restore_query = ( f"RESTORE DATABASE [{db_name}] FROM DISK = N'{backup_path}' " f"WITH {', '.join(move_clauses)}, REPLACE" ) app_logger.info(f"Comando RESTORE generado:\n{restore_query}") if dry_run: app_logger.info("Modo DRY RUN: No se ejecutará el RESTORE") return True, restore_query, None # Ejecutar RESTORE conn_str = self.get_connection_string() conn = pyodbc.connect(conn_str, timeout=timeout_minutes * 60) conn.autocommit = True # Necesario para ALTER DATABASE cursor = conn.cursor() start_time = time.time() app_logger.info(f"Ejecutando RESTORE DATABASE [{db_name}]...") output_lines = [] try: # 1. Single user (puede no existir la DB en una primera restauración) try: cursor.execute( f"ALTER DATABASE [{db_name}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE" ) output_lines.append("Base de datos configurada en modo SINGLE_USER") except Exception as e: app_logger.warning( f"No se pudo configurar SINGLE_USER (puede no existir la DB): {e}" ) # 2. RESTORE cursor.execute(restore_query) output_lines.append("RESTORE DATABASE completado") finally: # 3. Volver SIEMPRE a MULTI_USER, incluso si el RESTORE falló, para # no dejar la BD inaccesible en un servidor remoto compartido (G7). try: cursor.execute(f"ALTER DATABASE [{db_name}] SET MULTI_USER") output_lines.append("Base de datos configurada en modo MULTI_USER") except Exception as e: app_logger.error( f"No se pudo volver a MULTI_USER la BD [{db_name}] " f"(¿quedó en estado RESTORING tras un fallo?): {e}. " "Requiere intervención manual del DBA." ) elapsed = time.time() - start_time output_lines.append(f"Restauración completada en {elapsed:.2f}s") output = "\n".join(output_lines) app_logger.info(output) return True, output, None except Exception as e: error_msg = f"Error restaurando base de datos: {str(e)}" app_logger.error(error_msg) return False, None, error_msg finally: if conn is not None: try: conn.close() except Exception: app_logger.warning("No se pudo cerrar la conexión SQL tras el RESTORE") @staticmethod def _build_move_clauses(db_name, data_folder, logical_files) -> list: """ Genera una cláusula MOVE por archivo lógico con destino único, evitando colisiones cuando el backup tiene múltiples data files o logs (G4): - 1er data → {db}.mdf, siguientes → {db}_N.ndf - 1er log → {db}_log.ldf, siguientes → {db}_log_N.ldf - otros tipos (FILESTREAM/full-text) → {db}_{nombre_lógico_saneado} """ clauses = [] data_idx = 0 log_idx = 0 for lf in logical_files: if lf.type == 'D': suffix = "" if data_idx == 0 else f"_{data_idx}" ext = "mdf" if data_idx == 0 else "ndf" new_path = f"{data_folder}\\{db_name}{suffix}.{ext}" data_idx += 1 elif lf.type == 'L': suffix = "" if log_idx == 0 else f"_{log_idx}" new_path = f"{data_folder}\\{db_name}_log{suffix}.ldf" log_idx += 1 else: # No descartar otros tipos: moverlos preservando el nombre lógico. safe = "".join(c if c.isalnum() else "_" for c in lf.logical_name) new_path = f"{data_folder}\\{db_name}_{safe}" clauses.append(f"MOVE N'{lf.logical_name}' TO N'{new_path}'") return clauses def database_exists(self, db_name: str) -> bool: """ Verifica si una base de datos existe. Args: db_name: Nombre de la base de datos Returns: True si existe """ try: conn_str = self.get_connection_string() conn = pyodbc.connect(conn_str, timeout=10) cursor = conn.cursor() cursor.execute( "SELECT COUNT(*) FROM sys.databases WHERE name = ?", (db_name,) ) exists = cursor.fetchone()[0] > 0 conn.close() return exists except Exception as e: app_logger.error(f"Error verificando existencia de DB: {e}") return False