feature/integracion-panel-restore-targets

This commit is contained in:
2026-06-05 12:28:04 -06:00
parent afb76ce4a6
commit 072be5b5db
4 changed files with 176 additions and 31 deletions

View File

@@ -66,6 +66,93 @@ class SQLServerManager:
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.
@@ -199,40 +286,62 @@ class SQLServerManager:
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}"
)
restore_error: Optional[str] = None
# 2. RESTORE
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)
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."
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 completada en {elapsed:.2f}s")
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: