"""refresh_local_hub — orquesta el refresco del sistema local_hub. Pipeline impuro del dominio `pipelines`. Compone tres funciones del registry para regenerar la infraestructura que expone los servicios locales como subdominios `*.localhost`: 1. discover_local_services_py_infra — descubre y normaliza los servicios (manifiesto + servicios del registry con bloque `service:`), comprobando estado up/down. 2. render_caddyfile_py_infra — genera el fragmento de Caddyfile. 3. render_glance_config_py_infra — genera la config del widget monitor de Glance. Después escribe el fragmento de Caddyfile en /etc/caddy/conf.d/local_hub.caddy (el usuario tiene ACL de escritura ahí, sin sudo) y la config de Glance en apps/local_hub/glance/glance.yml. Si `reload=True`, recarga Caddy (admin API en localhost:2019, sin sudo) y reinicia la user-unit `glance` (sin sudo). Pensado para correrse a diario desde dag_engine con un step `function:`, o a mano: `fn run refresh_local_hub`. """ from __future__ import annotations import argparse import json import os import subprocess import sys from typing import Any import yaml ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) sys.path.insert(0, os.path.join(ROOT, "python", "functions")) from infra.discover_local_services import discover_local_services # noqa: E402 from infra.render_caddyfile import render_caddyfile # noqa: E402 from infra.render_glance_config import render_glance_config # noqa: E402 # Ruta del fragmento de Caddyfile. El usuario tiene ACL de escritura aquí (sin sudo). CADDY_FRAGMENT_PATH = "/etc/caddy/conf.d/local_hub.caddy" # Bloque fijo de servidor + tema que precede a la salida de render_glance_config. GLANCE_SERVER_BLOCK = ( "server:\n" " host: 127.0.0.1\n" " port: 8585\n" "\n" "theme:\n" " background-color: 240 8 9\n" " contrast-multiplier: 1.2\n" " primary-color: 210 90 70\n" "\n" ) def _default_manifest_path() -> str: """Ruta por defecto del manifiesto, derivada de la raíz del registry.""" root = os.environ.get("FN_REGISTRY_ROOT") or ROOT return os.path.join(root, "apps", "local_hub", "local_services.yaml") def _registry_root() -> str: """Raíz del registry: FN_REGISTRY_ROOT si está, si no la derivada del path del módulo.""" return os.environ.get("FN_REGISTRY_ROOT") or ROOT def refresh_local_hub( manifest_path: str | None = None, reload: bool = True, ) -> dict[str, Any]: """Refresca el sistema local_hub: descubre servicios, regenera configs y recarga. Args: manifest_path: ruta al manifiesto YAML del local_hub. Si es None, se usa ``/apps/local_hub/local_services.yaml`` (RAIZ derivada de FN_REGISTRY_ROOT o del path del propio módulo). reload: si True, recarga Caddy (admin API en localhost:2019) y reinicia la user-unit ``glance``. Si False, solo escribe las configs y no toca ningún servicio. Returns: dict resumen con las claves: total, up, down, caddy_path, glance_path, reloaded, caddy_reload_rc, glance_restart_rc, services. Raises: RuntimeError: si la config de Glance generada no es YAML parseable. """ if manifest_path is None: manifest_path = _default_manifest_path() # 1. Lee el manifiesto para extraer dashboard_subdomain y glance_port. 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"refresh_local_hub: no se puede leer el manifiesto {manifest_path}: {exc}" ) from exc dashboard_subdomain = manifest.get("dashboard_subdomain") or "home" glance_port = manifest.get("glance_port") or 8585 # 2. Descubre los servicios (manifiesto + registry). services = discover_local_services(manifest_path, include_registry=True) # 3. Bloque del dashboard para el Caddyfile. dashboard = {"subdomain": dashboard_subdomain, "port": glance_port} # 4. Renderiza el fragmento de Caddyfile. caddy_text = render_caddyfile(services, dashboard) # 5. Construye la config completa de Glance (bloque fijo + render_glance_config). glance_full = GLANCE_SERVER_BLOCK + render_glance_config(services) # Verifica que el YAML resultante es parseable antes de escribir nada. try: yaml.safe_load(glance_full) except yaml.YAMLError as exc: raise RuntimeError( f"refresh_local_hub: la config de Glance generada no es YAML válido: {exc}" ) from exc # 6. Escribe el fragmento de Caddyfile (ACL del usuario, sin sudo). with open(CADDY_FRAGMENT_PATH, "w", encoding="utf-8") as fh: fh.write(caddy_text) # 7. Escribe la config de Glance (crea el dir si falta). glance_path = os.path.join(_registry_root(), "apps", "local_hub", "glance", "glance.yml") os.makedirs(os.path.dirname(glance_path), exist_ok=True) with open(glance_path, "w", encoding="utf-8") as fh: fh.write(glance_full) up = sum(1 for s in services if s.get("up")) down = len(services) - up caddy_reload_rc: int | None = None glance_restart_rc: int | None = None # 8. Recarga Caddy y reinicia Glance (ambos sin sudo). if reload: try: caddy_proc = subprocess.run( ["caddy", "reload", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"], capture_output=True, text=True, timeout=30, ) caddy_reload_rc = caddy_proc.returncode except (OSError, subprocess.SubprocessError) as exc: caddy_reload_rc = -1 sys.stderr.write(f"refresh_local_hub: fallo recargando Caddy: {exc}\n") try: glance_proc = subprocess.run( ["systemctl", "--user", "restart", "glance"], capture_output=True, text=True, timeout=30, ) glance_restart_rc = glance_proc.returncode except (OSError, subprocess.SubprocessError) as exc: glance_restart_rc = -1 sys.stderr.write(f"refresh_local_hub: fallo reiniciando Glance: {exc}\n") return { "total": len(services), "up": up, "down": down, "caddy_path": CADDY_FRAGMENT_PATH, "glance_path": glance_path, "reloaded": reload, "caddy_reload_rc": caddy_reload_rc, "glance_restart_rc": glance_restart_rc, "services": [ { "name": s.get("name"), "subdomain": s.get("subdomain"), "port": s.get("port"), "up": s.get("up"), } for s in services ], } def main() -> None: parser = argparse.ArgumentParser(description="Refresca el sistema local_hub.") parser.add_argument( "--manifest-path", default=None, help="Ruta al manifiesto YAML del local_hub (default: /apps/local_hub/local_services.yaml).", ) parser.add_argument( "--no-reload", action="store_true", help="No recargar Caddy ni reiniciar Glance; solo regenerar las configs.", ) args = parser.parse_args() result = refresh_local_hub( manifest_path=args.manifest_path, reload=not args.no_reload, ) print(json.dumps(result, indent=2, ensure_ascii=False)) if __name__ == "__main__": main()