feat(infra): auto-commit con 56 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 14:22:55 +02:00
parent c1071a82b3
commit 32c7336bf6
56 changed files with 5307 additions and 100 deletions
@@ -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))