Files
CloudRecoveryAS/app/panel/panel_client.py

428 lines
15 KiB
Python

"""
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=<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, 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}"