"""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 _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: """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 """ driver = resolve_odbc_driver() parts = [ f"DRIVER={{{driver}}}", f"SERVER={self.server}", ] if "18" in driver: parts.append("TrustServerCertificate=yes") 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) @staticmethod def _drain_cursor(cursor) -> None: """Consume todos los result sets de comandos largos (RESTORE, etc.).""" while True: try: if cursor.description: cursor.fetchall() except Exception: pass if not cursor.nextset(): break def wait_for_database_state( self, db_name: str, target_state: str = "ONLINE", timeout_seconds: int = 300, poll_seconds: float = 2.0, ) -> Optional[str]: """Espera hasta que la BD alcance target_state o agote el timeout.""" deadline = time.time() + timeout_seconds last_state: Optional[str] = None while time.time() < deadline: last_state = self.get_database_state(db_name) if last_state == target_state: return last_state if last_state is None and target_state == "ONLINE": # Puede tardar en aparecer en sys.databases al inicio del RESTORE. pass time.sleep(poll_seconds) return last_state def get_database_state(self, db_name: str) -> Optional[str]: """Devuelve state_desc de sys.databases o None si no existe.""" try: conn_str = self.get_connection_string() conn = pyodbc.connect(conn_str, timeout=10) cursor = conn.cursor() cursor.execute( "SELECT state_desc FROM sys.databases WHERE name = ?", (db_name,), ) row = cursor.fetchone() conn.close() return str(row[0]) if row else None except Exception as e: app_logger.error(f"Error consultando estado de DB [{db_name}]: {e}") return None def _prepare_database_for_restore(self, cursor, db_name: str) -> None: """ Limpia una BD atascada en RESTORING u offline antes de un RESTORE nuevo. """ state = self.get_database_state(db_name) if not state: return if state == "RESTORING": app_logger.warning( f"BD [{db_name}] en RESTORING; intentando WITH RECOVERY..." ) try: cursor.execute(f"RESTORE DATABASE [{db_name}] WITH RECOVERY") self._drain_cursor(cursor) state = self.get_database_state(db_name) except Exception as e: app_logger.warning(f"RECOVERY falló para [{db_name}]: {e}") if state == "RESTORING": app_logger.warning( f"BD [{db_name}] sigue en RESTORING; eliminando con DROP DATABASE..." ) cursor.execute(f"DROP DATABASE [{db_name}]") self._drain_cursor(cursor) return if state == "ONLINE": try: cursor.execute( f"ALTER DATABASE [{db_name}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE" ) app_logger.info(f"BD [{db_name}] configurada en SINGLE_USER") except Exception as e: app_logger.warning( f"No se pudo configurar SINGLE_USER en [{db_name}]: {e}" ) 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 = [] restore_error: Optional[str] = None try: # 1. Limpiar BD atascada o poner SINGLE_USER si ya existe ONLINE self._prepare_database_for_restore(cursor, db_name) # 2. RESTORE (RECOVERY es el default; drenar result sets hasta completar) cursor.execute(restore_query) self._drain_cursor(cursor) output_lines.append("RESTORE DATABASE ejecutado") # Esperar a que SQL Server termine (evita borrar el .bak demasiado pronto) waited_state = self.wait_for_database_state( db_name, target_state="ONLINE", timeout_seconds=timeout_minutes * 60, ) if waited_state != "ONLINE": restore_error = ( f"BD [{db_name}] no alcanzó ONLINE tras RESTORE " f"(estado: {waited_state})" ) app_logger.error(restore_error) except Exception as e: restore_error = str(e) app_logger.error(f"RESTORE DATABASE falló para [{db_name}]: {e}") finally: # 3. MULTI_USER solo si la BD quedó ONLINE (G7) final_state = self.get_database_state(db_name) if final_state == "ONLINE": 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: restore_error = restore_error or ( f"No se pudo volver a MULTI_USER en [{db_name}]: {e}" ) app_logger.error(restore_error) elif final_state: msg = ( f"BD [{db_name}] quedó en estado {final_state} tras RESTORE" ) restore_error = restore_error or msg app_logger.error(msg) elapsed = time.time() - start_time output_lines.append(f"Restauración finalizada en {elapsed:.2f}s") output = "\n".join(output_lines) app_logger.info(output) if restore_error or self.get_database_state(db_name) != "ONLINE": state = self.get_database_state(db_name) err = restore_error or f"BD [{db_name}] no quedó ONLINE (estado: {state})" return False, output, err 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