diff --git a/QUICKSTART.md b/QUICKSTART.md index 40a3ab1..0f5d187 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -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. +**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) 1. **Tab Configuración**: diff --git a/app/constants.py b/app/constants.py index 7d9cc14..ab05e33 100644 --- a/app/constants.py +++ b/app/constants.py @@ -89,5 +89,7 @@ DEFAULT_CONFIG = { # Nombre del restore_target en el panel (identidad de este CRA). # Con panel configurado: enrutamiento automático restore_local | forward. "instance_key": "", + # False en dev: el panel local usa HTTPS con certificado propio. + "verify_ssl": False, } } diff --git a/app/panel/panel_client.py b/app/panel/panel_client.py index cdf3db2..17bd40e 100644 --- a/app/panel/panel_client.py +++ b/app/panel/panel_client.py @@ -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. """ +import os from typing import Optional 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. 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). REQUIRED_TARGET_FIELDS = ( "server", @@ -109,7 +122,9 @@ def get_target_for_database( headers = {"Authorization": f"Bearer {api_token.strip()}"} 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: app_logger.error(f"PANEL no accesible al consultar servidor de la base '{db_name}': {e}") 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()}"} 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: app_logger.error(f"PANEL no accesible al consultar catálogo de servidores: {e}") return [] @@ -221,7 +238,9 @@ def resolve_route( headers = {"Authorization": f"Bearer {api_token.strip()}"} 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: app_logger.error(f"PANEL no accesible al resolver ruta para '{filename}': {e}") return None @@ -313,7 +332,9 @@ def report_instance_config( payload["instance_key"] = key 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: app_logger.error(f"No se pudo reportar la carpeta de entrada al PANEL: {e}") return False @@ -360,7 +381,9 @@ def report_job_result( } 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: app_logger.error(f"No se pudo reportar el resultado del job al PANEL: {e}") 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__')}" headers = {"Authorization": f"Bearer {api_token.strip()}"} 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: return False, f"No se pudo conectar al PANEL: {e}" diff --git a/app/sql/sql_manager.py b/app/sql/sql_manager.py index 0774087..baaf86f 100644 --- a/app/sql/sql_manager.py +++ b/app/sql/sql_manager.py @@ -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: