feature/integracion-panel-restore-targets
This commit is contained in:
@@ -24,6 +24,15 @@ Get-OdbcDriver | Where-Object {$_.Name -like "*SQL Server*"}
|
|||||||
|
|
||||||
Si usas el panel para asignar servidores de restauración, ver [INTEGRACION_PANEL.md](INTEGRACION_PANEL.md) para tokens, catálogo dinámico (`target-catalog`) y prueba end-to-end.
|
Si usas el panel para asignar servidores de restauración, ver [INTEGRACION_PANEL.md](INTEGRACION_PANEL.md) para tokens, catálogo dinámico (`target-catalog`) y prueba end-to-end.
|
||||||
|
|
||||||
|
**Prueba local en tu PC** (carpetas + config automática):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\prepare_local_test.ps1
|
||||||
|
.\venv\Scripts\python.exe runner.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Copia el ZIP a `C:\CloudRestore\Entrada` (ver `LEEME.txt` en esa carpeta).
|
||||||
|
|
||||||
### 3. Configuración Básica (en la aplicación)
|
### 3. Configuración Básica (en la aplicación)
|
||||||
|
|
||||||
1. **Tab Configuración**:
|
1. **Tab Configuración**:
|
||||||
|
|||||||
@@ -89,5 +89,7 @@ DEFAULT_CONFIG = {
|
|||||||
# Nombre del restore_target en el panel (identidad de este CRA).
|
# Nombre del restore_target en el panel (identidad de este CRA).
|
||||||
# Con panel configurado: enrutamiento automático restore_local | forward.
|
# Con panel configurado: enrutamiento automático restore_local | forward.
|
||||||
"instance_key": "",
|
"instance_key": "",
|
||||||
|
# False en dev: el panel local usa HTTPS con certificado propio.
|
||||||
|
"verify_ssl": False,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ asignado, get_target_for_database devuelve None y el worker deja el job en cola
|
|||||||
reintentar; nunca se restaura con datos inciertos.
|
reintentar; nunca se restaura con datos inciertos.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import urlparse, quote
|
from urllib.parse import urlparse, quote
|
||||||
|
|
||||||
@@ -21,6 +22,18 @@ from ..utils.logger import app_logger
|
|||||||
# Timeout (connect, read) en segundos para las llamadas al PANEL.
|
# Timeout (connect, read) en segundos para las llamadas al PANEL.
|
||||||
DEFAULT_TIMEOUT = (5, 10)
|
DEFAULT_TIMEOUT = (5, 10)
|
||||||
|
|
||||||
|
|
||||||
|
def _http_verify(verify_ssl: Optional[bool] = None) -> bool:
|
||||||
|
"""Verificación TLS para requests. Por defecto False (panel dev con cert propio)."""
|
||||||
|
if verify_ssl is not None:
|
||||||
|
return verify_ssl
|
||||||
|
env = os.getenv("CLOUDRESTORE_PANEL_VERIFY_SSL", "").strip().lower()
|
||||||
|
if env in ("1", "true", "yes"):
|
||||||
|
return True
|
||||||
|
if env in ("0", "false", "no"):
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
# Campos obligatorios del servidor devuelto por el endpoint (modo hub central + SFTP).
|
# Campos obligatorios del servidor devuelto por el endpoint (modo hub central + SFTP).
|
||||||
REQUIRED_TARGET_FIELDS = (
|
REQUIRED_TARGET_FIELDS = (
|
||||||
"server",
|
"server",
|
||||||
@@ -109,7 +122,9 @@ def get_target_for_database(
|
|||||||
headers = {"Authorization": f"Bearer {api_token.strip()}"}
|
headers = {"Authorization": f"Bearer {api_token.strip()}"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = requests.get(url, headers=headers, timeout=DEFAULT_TIMEOUT)
|
resp = requests.get(
|
||||||
|
url, headers=headers, timeout=DEFAULT_TIMEOUT, verify=_http_verify()
|
||||||
|
)
|
||||||
except requests.exceptions.RequestException as e:
|
except requests.exceptions.RequestException as e:
|
||||||
app_logger.error(f"PANEL no accesible al consultar servidor de la base '{db_name}': {e}")
|
app_logger.error(f"PANEL no accesible al consultar servidor de la base '{db_name}': {e}")
|
||||||
return None
|
return None
|
||||||
@@ -158,7 +173,9 @@ def list_restore_target_names(api_url: str, api_token: str) -> list[str]:
|
|||||||
headers = {"Authorization": f"Bearer {api_token.strip()}"}
|
headers = {"Authorization": f"Bearer {api_token.strip()}"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = requests.get(url, headers=headers, timeout=DEFAULT_TIMEOUT)
|
resp = requests.get(
|
||||||
|
url, headers=headers, timeout=DEFAULT_TIMEOUT, verify=_http_verify()
|
||||||
|
)
|
||||||
except requests.exceptions.RequestException as e:
|
except requests.exceptions.RequestException as e:
|
||||||
app_logger.error(f"PANEL no accesible al consultar catálogo de servidores: {e}")
|
app_logger.error(f"PANEL no accesible al consultar catálogo de servidores: {e}")
|
||||||
return []
|
return []
|
||||||
@@ -221,7 +238,9 @@ def resolve_route(
|
|||||||
headers = {"Authorization": f"Bearer {api_token.strip()}"}
|
headers = {"Authorization": f"Bearer {api_token.strip()}"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = requests.get(url, headers=headers, timeout=DEFAULT_TIMEOUT)
|
resp = requests.get(
|
||||||
|
url, headers=headers, timeout=DEFAULT_TIMEOUT, verify=_http_verify()
|
||||||
|
)
|
||||||
except requests.exceptions.RequestException as e:
|
except requests.exceptions.RequestException as e:
|
||||||
app_logger.error(f"PANEL no accesible al resolver ruta para '{filename}': {e}")
|
app_logger.error(f"PANEL no accesible al resolver ruta para '{filename}': {e}")
|
||||||
return None
|
return None
|
||||||
@@ -313,7 +332,9 @@ def report_instance_config(
|
|||||||
payload["instance_key"] = key
|
payload["instance_key"] = key
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = requests.post(url, json=payload, headers=headers, timeout=DEFAULT_TIMEOUT)
|
resp = requests.post(
|
||||||
|
url, json=payload, headers=headers, timeout=DEFAULT_TIMEOUT, verify=_http_verify()
|
||||||
|
)
|
||||||
except requests.exceptions.RequestException as e:
|
except requests.exceptions.RequestException as e:
|
||||||
app_logger.error(f"No se pudo reportar la carpeta de entrada al PANEL: {e}")
|
app_logger.error(f"No se pudo reportar la carpeta de entrada al PANEL: {e}")
|
||||||
return False
|
return False
|
||||||
@@ -360,7 +381,9 @@ def report_job_result(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = requests.post(url, json=payload, headers=headers, timeout=DEFAULT_TIMEOUT)
|
resp = requests.post(
|
||||||
|
url, json=payload, headers=headers, timeout=DEFAULT_TIMEOUT, verify=_http_verify()
|
||||||
|
)
|
||||||
except requests.exceptions.RequestException as e:
|
except requests.exceptions.RequestException as e:
|
||||||
app_logger.error(f"No se pudo reportar el resultado del job al PANEL: {e}")
|
app_logger.error(f"No se pudo reportar el resultado del job al PANEL: {e}")
|
||||||
return False
|
return False
|
||||||
@@ -390,7 +413,9 @@ def test_connection(api_url: str, api_token: str) -> tuple[bool, Optional[str]]:
|
|||||||
url = f"{base}/api/restore/target-for?database={quote('__cloudrestore_ping__')}"
|
url = f"{base}/api/restore/target-for?database={quote('__cloudrestore_ping__')}"
|
||||||
headers = {"Authorization": f"Bearer {api_token.strip()}"}
|
headers = {"Authorization": f"Bearer {api_token.strip()}"}
|
||||||
try:
|
try:
|
||||||
resp = requests.get(url, headers=headers, timeout=DEFAULT_TIMEOUT)
|
resp = requests.get(
|
||||||
|
url, headers=headers, timeout=DEFAULT_TIMEOUT, verify=_http_verify()
|
||||||
|
)
|
||||||
except requests.exceptions.RequestException as e:
|
except requests.exceptions.RequestException as e:
|
||||||
return False, f"No se pudo conectar al PANEL: {e}"
|
return False, f"No se pudo conectar al PANEL: {e}"
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,93 @@ class SQLServerManager:
|
|||||||
|
|
||||||
return ";".join(parts)
|
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]]:
|
def test_connection(self) -> Tuple[bool, Optional[str]]:
|
||||||
"""
|
"""
|
||||||
Prueba la conexión a SQL Server.
|
Prueba la conexión a SQL Server.
|
||||||
@@ -199,40 +286,62 @@ class SQLServerManager:
|
|||||||
app_logger.info(f"Ejecutando RESTORE DATABASE [{db_name}]...")
|
app_logger.info(f"Ejecutando RESTORE DATABASE [{db_name}]...")
|
||||||
|
|
||||||
output_lines = []
|
output_lines = []
|
||||||
try:
|
restore_error: Optional[str] = None
|
||||||
# 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
|
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)
|
cursor.execute(restore_query)
|
||||||
output_lines.append("RESTORE DATABASE completado")
|
self._drain_cursor(cursor)
|
||||||
finally:
|
output_lines.append("RESTORE DATABASE ejecutado")
|
||||||
# 3. Volver SIEMPRE a MULTI_USER, incluso si el RESTORE falló, para
|
|
||||||
# no dejar la BD inaccesible en un servidor remoto compartido (G7).
|
# Esperar a que SQL Server termine (evita borrar el .bak demasiado pronto)
|
||||||
try:
|
waited_state = self.wait_for_database_state(
|
||||||
cursor.execute(f"ALTER DATABASE [{db_name}] SET MULTI_USER")
|
db_name,
|
||||||
output_lines.append("Base de datos configurada en modo MULTI_USER")
|
target_state="ONLINE",
|
||||||
except Exception as e:
|
timeout_seconds=timeout_minutes * 60,
|
||||||
app_logger.error(
|
)
|
||||||
f"No se pudo volver a MULTI_USER la BD [{db_name}] "
|
if waited_state != "ONLINE":
|
||||||
f"(¿quedó en estado RESTORING tras un fallo?): {e}. "
|
restore_error = (
|
||||||
"Requiere intervención manual del DBA."
|
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
|
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)
|
output = "\n".join(output_lines)
|
||||||
app_logger.info(output)
|
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
|
return True, output, None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
Reference in New Issue
Block a user