feat(infra): auto-commit con 56 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
"""Descubre servicios locales expuestos como subdominios ``*.localhost``.
|
||||
|
||||
Fase de descubrimiento del sistema ``local_hub`` (reverse proxy Caddy +
|
||||
dashboard Glance). Lee el manifiesto YAML de servicios, normaliza la metadata
|
||||
de cada uno, opcionalmente añade los servicios del registry con puerto, y
|
||||
comprueba si cada servicio está vivo (puerto TCP aceptando conexiones).
|
||||
|
||||
La función es robusta: nunca lanza por un servicio caído (lo marca ``up=False``)
|
||||
ni por un fallo de ``fn doctor`` (captura y continúa solo con el manifiesto).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
# Alias para derivar el subdominio de un servicio del registry a partir de su
|
||||
# nombre. Para los nombres no listados se usa el primer token antes de "_".
|
||||
_SUBDOMAIN_ALIAS = {
|
||||
"dag_engine": "dag",
|
||||
"registry_api": "registry",
|
||||
"sqlite_api": "sqlite",
|
||||
"osint_db": "osint",
|
||||
"services_api": "services",
|
||||
"web_proxy": "proxy",
|
||||
}
|
||||
|
||||
|
||||
def _is_port_up(port: int) -> bool:
|
||||
"""Devuelve True si 127.0.0.1:<port> acepta conexiones TCP.
|
||||
|
||||
Comprobación pura de puerto (no HTTP): algunos servicios no responden 200
|
||||
en ``/`` pero sí aceptan la conexión. Timeout corto para no bloquear.
|
||||
"""
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", int(port)), timeout=0.5):
|
||||
return True
|
||||
except (OSError, ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def _normalize_manifest_service(svc: dict) -> dict:
|
||||
"""Normaliza un servicio del manifiesto a todas las claves esperadas."""
|
||||
name = str(svc.get("name", "")).strip()
|
||||
subdomain = str(svc.get("subdomain", "") or name).strip()
|
||||
try:
|
||||
port = int(svc.get("port", 0) or 0)
|
||||
except (ValueError, TypeError):
|
||||
port = 0
|
||||
return {
|
||||
"name": name,
|
||||
"subdomain": subdomain,
|
||||
"port": port,
|
||||
"health_path": str(svc.get("health_path") or "/"),
|
||||
"title": str(svc.get("title") or name),
|
||||
"icon": str(svc.get("icon") or ""),
|
||||
"category": str(svc.get("category") or "Otros"),
|
||||
"rewrite_host": bool(svc.get("rewrite_host", False)),
|
||||
"up": _is_port_up(port) if port > 0 else False,
|
||||
}
|
||||
|
||||
|
||||
def _is_registry_root(path: str) -> bool:
|
||||
"""Una raíz válida del registry tiene registry.db Y el paquete cmd/fn.
|
||||
|
||||
El doble marcador evita falsos positivos como un registry.db vacío y
|
||||
espurio dentro de python/ (la regla db_locations exige que registry.db
|
||||
solo viva en la raíz, pero protegemos por si acaso).
|
||||
"""
|
||||
return os.path.isfile(os.path.join(path, "registry.db")) and os.path.isdir(
|
||||
os.path.join(path, "cmd", "fn")
|
||||
)
|
||||
|
||||
|
||||
def _find_registry_root() -> str:
|
||||
"""Localiza la raíz del registry (FN_REGISTRY_ROOT o subiendo hasta la raíz real)."""
|
||||
root = os.environ.get("FN_REGISTRY_ROOT")
|
||||
if root and _is_registry_root(root):
|
||||
return root
|
||||
cur = os.path.abspath(os.path.dirname(__file__))
|
||||
while True:
|
||||
if _is_registry_root(cur):
|
||||
return cur
|
||||
parent = os.path.dirname(cur)
|
||||
if parent == cur:
|
||||
break
|
||||
cur = parent
|
||||
return os.getcwd()
|
||||
|
||||
|
||||
def _derive_subdomain(name: str) -> str:
|
||||
"""Deriva un subdominio del nombre de un servicio del registry."""
|
||||
name = (name or "").strip()
|
||||
if name in _SUBDOMAIN_ALIAS:
|
||||
return _SUBDOMAIN_ALIAS[name]
|
||||
return name.split("_", 1)[0] if name else name
|
||||
|
||||
|
||||
def _fetch_registry_services(root: str) -> list[dict]:
|
||||
"""Obtiene los servicios del registry con puerto via ``fn doctor``.
|
||||
|
||||
Intenta ``fn doctor services-spec --json`` y cae a ``services --json``.
|
||||
Devuelve lista vacía si ambos fallan (la función sigue con el manifiesto).
|
||||
"""
|
||||
fn_bin = os.path.join(root, "fn")
|
||||
cmd_base = [fn_bin] if os.path.isfile(fn_bin) else ["fn"]
|
||||
for sub in ("services-spec", "services"):
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd_base + ["doctor", sub, "--json"],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
continue
|
||||
if proc.returncode != 0 or not proc.stdout.strip():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(proc.stdout)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
if isinstance(data, dict):
|
||||
for key in ("services", "items", "results"):
|
||||
if isinstance(data.get(key), list):
|
||||
data = data[key]
|
||||
break
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
return []
|
||||
|
||||
|
||||
def discover_local_services(manifest_path: str, include_registry: bool = True) -> list[dict]:
|
||||
"""Descubre y normaliza los servicios locales del manifiesto local_hub.
|
||||
|
||||
Args:
|
||||
manifest_path: ruta al manifiesto YAML (``apps/local_hub/local_services.yaml``).
|
||||
include_registry: si True añade los servicios del registry con puerto>0 que
|
||||
no estén ya en el manifiesto (dedup por port y por subdomain).
|
||||
|
||||
Returns:
|
||||
Lista de dicts normalizados, cada uno con las claves: name, subdomain, port,
|
||||
health_path, title, icon, category, rewrite_host, up. La clave
|
||||
``rewrite_host`` (bool) es passthrough del manifiesto (default ``False``;
|
||||
siempre ``False`` para los servicios añadidos desde el registry) y la
|
||||
consume ``render_caddyfile`` para reescribir el header ``Host`` del
|
||||
upstream en servicios que lo validan (ej. Jupyter).
|
||||
"""
|
||||
try:
|
||||
with open(manifest_path, "r", encoding="utf-8") as fh:
|
||||
manifest = yaml.safe_load(fh) or {}
|
||||
except (OSError, yaml.YAMLError) as exc:
|
||||
raise RuntimeError(f"discover_local_services: cannot read manifest {manifest_path}: {exc}") from exc
|
||||
|
||||
raw_services = manifest.get("services") or []
|
||||
result: list[dict] = []
|
||||
seen_ports: set[int] = set()
|
||||
seen_subdomains: set[str] = set()
|
||||
|
||||
for svc in raw_services:
|
||||
if not isinstance(svc, dict):
|
||||
continue
|
||||
norm = _normalize_manifest_service(svc)
|
||||
result.append(norm)
|
||||
if norm["port"] > 0:
|
||||
seen_ports.add(norm["port"])
|
||||
if norm["subdomain"]:
|
||||
seen_subdomains.add(norm["subdomain"])
|
||||
|
||||
if include_registry:
|
||||
try:
|
||||
root = _find_registry_root()
|
||||
for svc in _fetch_registry_services(root):
|
||||
if not isinstance(svc, dict):
|
||||
continue
|
||||
try:
|
||||
port = int(svc.get("port", 0) or 0)
|
||||
except (ValueError, TypeError):
|
||||
port = 0
|
||||
if port <= 0 or port in seen_ports:
|
||||
continue
|
||||
name = str(svc.get("name", "")).strip()
|
||||
subdomain = _derive_subdomain(name)
|
||||
if subdomain in seen_subdomains:
|
||||
continue
|
||||
result.append({
|
||||
"name": name,
|
||||
"subdomain": subdomain,
|
||||
"port": port,
|
||||
"health_path": str(svc.get("health_endpoint") or "/"),
|
||||
"title": name,
|
||||
"icon": "",
|
||||
"category": "Registry",
|
||||
"rewrite_host": False,
|
||||
"up": _is_port_up(port),
|
||||
})
|
||||
seen_ports.add(port)
|
||||
seen_subdomains.add(subdomain)
|
||||
except Exception:
|
||||
# fn doctor opcional: si algo falla, seguimos solo con el manifiesto.
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
path = sys.argv[1] if len(sys.argv) > 1 else "apps/local_hub/local_services.yaml"
|
||||
services = discover_local_services(path)
|
||||
print(json.dumps(services, indent=2, ensure_ascii=False))
|
||||
Reference in New Issue
Block a user