415 lines
15 KiB
Python
415 lines
15 KiB
Python
"""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)
|
|
|
|
@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
|