""" Cliente HTTP para PANEL_BASES_ANEXO24. Cada base de datos está asignada en el PANEL a un servidor de restauración (registrado en restore_targets). CloudRestoreAS, tras resolver el db_name del archivo, pregunta al PANEL (polling) qué servidor corresponde a ESA base y restaura ahí; al terminar, reporta el resultado del job. Política ante fallo (G8/G9): si el PANEL no responde o la base no tiene servidor asignado, get_target_for_database devuelve None y el worker deja el job en cola para reintentar; nunca se restaura con datos inciertos. """ import os from typing import Optional from urllib.parse import urlparse, quote import requests 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", "username", "password", "data_folder", "ssh_host", "ssh_username", "ssh_password", "remote_inbox_path", ) # Modo colocado (CRA en el mismo servidor): solo credenciales SQL locales. REQUIRED_COLOCATED_TARGET_FIELDS = ( "name", "server", "username", "password", "data_folder", ) # Reenvío de ZIP por SFTP al input_folder del destino. REQUIRED_FORWARD_TARGET_FIELDS = ( "name", "ssh_host", "ssh_username", "ssh_password", "input_folder", ) def _normalize_base_url(api_url: str) -> Optional[str]: """Valida y normaliza la URL base del PANEL. Devuelve None si es inválida.""" if not api_url or not api_url.strip(): return None base = api_url.strip().rstrip("/") parsed = urlparse(base) # Solo http/https; evita esquemas peligrosos (file://, etc.) if parsed.scheme not in ("http", "https") or not parsed.netloc: app_logger.error(f"URL de PANEL inválida (esquema/host): {api_url}") return None return base def _validate_target(data: dict, *, colocated: bool = False) -> bool: """Verifica que el servidor devuelto tenga todos los campos requeridos no vacíos.""" fields = REQUIRED_COLOCATED_TARGET_FIELDS if colocated else REQUIRED_TARGET_FIELDS for field in fields: value = data.get(field) if value is None or (isinstance(value, str) and not value.strip()): app_logger.error(f"Servidor del PANEL sin campo requerido: '{field}'") return False return True def get_target_for_database( api_url: str, api_token: str, db_name: str, instance_key: Optional[str] = None, ) -> Optional[dict]: """ Consulta GET /api/restore/target-for?database=: el servidor de restauración asignado a esa base de datos. Returns: dict con id, name, server, username, password, data_folder, ssh_host, ssh_port, ssh_username, ssh_password, remote_inbox_path si la base tiene servidor asignado; None si no tiene asignación, el PANEL no responde o la config es inválida. """ base = _normalize_base_url(api_url) if base is None: return None if not api_token or not api_token.strip(): app_logger.error("Token de PANEL no configurado; no se puede consultar el servidor de la base") return None if not db_name or not db_name.strip(): app_logger.error("db_name vacío; no se puede consultar el servidor de la base") return None colocated = bool(instance_key and instance_key.strip()) url = f"{base}/api/restore/target-for?database={quote(db_name.strip())}" if colocated: url += f"&instance={quote(instance_key.strip())}" headers = {"Authorization": f"Bearer {api_token.strip()}"} try: 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 if resp.status_code == 404: app_logger.warning(f"La base '{db_name}' no tiene servidor de restauración asignado en el PANEL") return None if resp.status_code == 401: app_logger.error("PANEL rechazó el token de CloudRestoreAS (401)") return None if resp.status_code != 200: app_logger.error(f"PANEL respondió {resp.status_code} al consultar servidor de la base") return None try: data = resp.json() except ValueError: app_logger.error("Respuesta del PANEL no es JSON válido") return None if not _validate_target(data, colocated=colocated): return None name = data.get("name") or data.get("server") app_logger.info(f"Servidor para la base '{db_name}': {name} ({data.get('server')})") return data def list_restore_target_names(api_url: str, api_token: str) -> list[str]: """ Consulta GET /api/restore/target-catalog: nombres de servidores de restauración registrados en el panel (para llenar el selector de instancia). Returns: Lista de nombres; lista vacía si falla la conexión, el token es inválido o la respuesta no es válida. """ base = _normalize_base_url(api_url) if base is None: return [] if not api_token or not api_token.strip(): app_logger.error("Token de PANEL no configurado; no se puede consultar el catálogo") return [] url = f"{base}/api/restore/target-catalog" headers = {"Authorization": f"Bearer {api_token.strip()}"} try: 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 [] if resp.status_code == 401: app_logger.error("PANEL rechazó el token de CloudRestoreAS (401) al consultar catálogo") return [] if resp.status_code != 200: app_logger.error(f"PANEL respondió {resp.status_code} al consultar catálogo de servidores") return [] try: data = resp.json() except ValueError: app_logger.error("Respuesta del PANEL (target-catalog) no es JSON válido") return [] targets = data.get("targets") if not isinstance(targets, list): app_logger.error("Respuesta del PANEL (target-catalog) sin lista 'targets'") return [] names: list[str] = [] for item in targets: if isinstance(item, dict): name = item.get("name") if isinstance(name, str) and name.strip(): names.append(name.strip()) return names def resolve_route( api_url: str, api_token: str, filename: str, instance_key: Optional[str] = None, ) -> Optional[dict]: """ Consulta GET /api/restore/resolve-route: resuelve nodo/destino desde el nombre del archivo y devuelve action (restore_local | forward) más target completo. Returns: dict con action, db_name, node_key, target; None si el panel no responde, token inválido o la respuesta no cumple el contrato. """ base = _normalize_base_url(api_url) if base is None: return None if not api_token or not api_token.strip(): app_logger.error("Token de PANEL no configurado; no se puede resolver ruta") return None if not filename or not filename.strip(): app_logger.error("filename vacío; no se puede resolver ruta") return None url = f"{base}/api/restore/resolve-route?filename={quote(filename.strip())}" key = (instance_key or "").strip() if key: url += f"&instance={quote(key)}" headers = {"Authorization": f"Bearer {api_token.strip()}"} try: 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 if resp.status_code == 404: app_logger.warning(f"El archivo '{filename}' no tiene nodo/base asignado en el PANEL") return None if resp.status_code == 503: app_logger.warning( f"Destino aún sin carpeta de entrada reportada para '{filename}'" ) return None if resp.status_code == 401: app_logger.error("PANEL rechazó el token de CloudRestoreAS (401) al resolver ruta") return None if resp.status_code != 200: app_logger.error(f"PANEL respondió {resp.status_code} al resolver ruta") return None try: data = resp.json() except ValueError: app_logger.error("Respuesta del PANEL (resolve-route) no es JSON válido") return None action = data.get("action") target = data.get("target") db_name = data.get("db_name") if action not in ("restore_local", "forward") or not isinstance(target, dict): app_logger.error("Respuesta del PANEL (resolve-route) con action o target inválidos") return None if not isinstance(db_name, str) or not db_name.strip(): app_logger.error("Respuesta del PANEL (resolve-route) sin db_name válido") return None if action == "restore_local": if not _validate_target(target, colocated=True): return None elif not _validate_target_fields(target, REQUIRED_FORWARD_TARGET_FIELDS): return None app_logger.info( f"Ruta para '{filename}': action={action}, destino={target.get('name')}, db={db_name}" ) return data def _validate_target_fields(data: dict, fields: tuple) -> bool: """Verifica campos requeridos no vacíos (lista explícita).""" for field in fields: value = data.get(field) if value is None or (isinstance(value, str) and not value.strip()): app_logger.error(f"Servidor del PANEL sin campo requerido para forward: '{field}'") return False return True def report_instance_config( api_url: str, api_token: str, input_folder: str, host_name: Optional[str] = None, app_version: Optional[str] = None, instance_key: Optional[str] = None, ) -> bool: """ Reporta la carpeta de entrada vigente a POST /api/restore/instance-config (best-effort). El panel solo la muestra; CloudRestoreAS es la única fuente de escritura. Returns: True si el PANEL aceptó el reporte (200), False en cualquier otro caso. """ base = _normalize_base_url(api_url) if base is None or not api_token or not api_token.strip(): return False if not input_folder or not input_folder.strip(): app_logger.warning("input_folder vacío; no se reporta al PANEL") return False url = f"{base}/api/restore/instance-config" headers = {"Authorization": f"Bearer {api_token.strip()}"} key = (instance_key or "").strip() or None payload = { "input_folder": input_folder.strip(), "host_name": (host_name or "").strip() or None, "app_version": (app_version or "").strip() or None, } if key: payload["instance_key"] = key try: 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 if resp.status_code == 200: app_logger.info(f"Carpeta de entrada reportada al PANEL: {input_folder.strip()}") return True app_logger.error( f"PANEL respondió {resp.status_code} al reportar la carpeta de entrada" ) return False def report_job_result( api_url: str, api_token: str, filename: str, status: str, restore_target_id: Optional[int] = None, db_name: Optional[str] = None, duration_ms: Optional[int] = None, error_message: Optional[str] = None, ) -> bool: """ Reporta el resultado de un job a POST /api/restore/job-result (best-effort). No lanza excepciones: un fallo aquí no debe romper el flujo de restauración. Returns: True si el PANEL aceptó el reporte (201), False en cualquier otro caso. """ base = _normalize_base_url(api_url) if base is None or not api_token or not api_token.strip(): return False url = f"{base}/api/restore/job-result" headers = {"Authorization": f"Bearer {api_token.strip()}"} payload = { "filename": filename, "restore_target_id": restore_target_id, "db_name": db_name, "status": status, "duration_ms": duration_ms, "error_message": error_message, } try: 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 if resp.status_code == 201: return True app_logger.error(f"PANEL respondió {resp.status_code} al reportar el resultado del job") return False def test_connection(api_url: str, api_token: str) -> tuple[bool, Optional[str]]: """ Prueba conectividad y autenticación contra el PANEL para la UI de configuración. Consulta /target-for con un nombre de base inexistente: un 404 confirma que el PANEL responde y el token es válido (la base simplemente no existe). Returns: (True, mensaje) si el PANEL responde y el token es aceptado; (False, "mensaje de error") si no se pudo conectar o el token fue rechazado. """ base = _normalize_base_url(api_url) if base is None: return False, "URL de PANEL inválida (debe iniciar con http:// o https://)" if not api_token or not api_token.strip(): return False, "Falta el token de API del PANEL" 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, verify=_http_verify() ) except requests.exceptions.RequestException as e: return False, f"No se pudo conectar al PANEL: {e}" # 200 (base de ping existiera) o 404 (no existe) → conexión y token OK. if resp.status_code in (200, 404): return True, "Conexión y token correctos" if resp.status_code == 401: return False, "Token rechazado por el PANEL (401)" return False, f"El PANEL respondió con código {resp.status_code}"