feature/integracion-panel-restore-targets
This commit is contained in:
1
app/panel/__init__.py
Normal file
1
app/panel/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Integración con PANEL_BASES_ANEXO24 (servidor de restauración activo)."""
|
||||
402
app/panel/panel_client.py
Normal file
402
app/panel/panel_client.py
Normal file
@@ -0,0 +1,402 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
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)
|
||||
|
||||
# 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=<db_name>: 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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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}"
|
||||
Reference in New Issue
Block a user