"""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: 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))