# ============================================ # Projet : API-KA # Fichier : src/ka_registry.py # Author : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Date : 2026-09-04 # ============================================ """Résolution des URL des apps sœurs depuis le registre maclustr-dispatch. Les apps Ka bougent de nœud (``mld move``) : une URL figée dans le .env (127.0.0.1, nom mDNS d'un nœud) devient fausse — incident du 2026-09-04 : ``LOUKA_SOURCE_URL=127.0.0.1:8095`` pointait sur auto-ka après la migration, 421 connecteurs lou-ka passés « stale » et 6 collectes cassées. Le registre (``data/registry.json``) est poussé par la passerelle M1M32 à chaque sauvegarde (``mld subscribers``). Pour chaque service on construit ``http://:`` : loopback si l'app est sur le même nœud qu'API-KA (beaucoup n'écoutent qu'en local), IP LAN sinon. Le chemin est repris de la variable ``*_SOURCE_URL`` du .env (repli : table par défaut). Sans registre ou sans entrée, l'URL du .env est utilisée telle quelle. """ from __future__ import annotations import json import os import time from pathlib import Path from urllib.parse import urlsplit SERVICE_APP: dict[str, str] = { "louka": "lou-ka", "immoka": "immo-ka", "foodka": "food-ka", "autoka": "auto-ka", "fabrika": "fabri-ka", "restoka": "resto-ka", "sortika": "sorti-ka", "creaka": "crea-ka", "jobka": "job-ka", "houseka": "house-ka", "rentka": "rent-ka", } DEFAULT_PATH: dict[str, str] = { "louka": "/api/listings", "immoka": "/api/listings", "houseka": "/api/listings", "rentka": "/api/listings", "foodka": "/api/products", "fabrika": "/api/products", "autoka": "/api/vehicles", "restoka": "/api/restaurants", "sortika": "/api/events", "creaka": "/api/creators", "jobka": "/api/jobs", } _BASE = Path(__file__).resolve().parents[1] _CANDIDATES = [ os.getenv("KA_REGISTRY_PATH", ""), str(_BASE / "data" / "registry.json"), str(Path.home() / "dispatch" / "registry.json"), ] _cache: dict = {"path": None, "mtime": 0.0, "checked": 0.0, "apps": {}} _self_node: str | None = None _last_logged: dict[str, str] = {} def self_node() -> str: global _self_node if _self_node is None: try: _self_node = (Path.home() / ".maclustr-node").read_text().strip() except OSError: _self_node = os.getenv("NODE_NAME", "") return _self_node def registry_apps() -> dict: """Entrées ``apps`` du registre, rechargées si le fichier a changé (≤ 1 stat/10 s).""" now = time.time() if now - _cache["checked"] < 10: return _cache["apps"] _cache["checked"] = now for p in _CANDIDATES: if not p or not os.path.exists(p): continue try: mtime = os.stat(p).st_mtime if p == _cache["path"] and mtime <= _cache["mtime"]: return _cache["apps"] with open(p) as f: data = json.load(f) apps = data.get("apps") if isinstance(data, dict) else None if isinstance(apps, dict): _cache.update({"path": p, "mtime": mtime, "apps": apps}) return apps except (OSError, ValueError): continue return _cache["apps"] def _candidates(service: str, entry: dict, path: str) -> list[tuple[str, str]]: """Routes possibles vers l'app, de la plus locale à la plus publique : loopback (même nœud) → IP LAN → nom Tailscale du nœud → domaine public (ngrok). Sur macOS 26, un python Homebrew lancé sans session graphique se voit refuser le LAN et Tailscale (« Local Network Privacy », Errno 65) : le domaine public reste alors la seule route — d'où le repli automatique.""" node = entry.get("node") or "" port = entry["port"] out: list[tuple[str, str]] = [] if node and node == self_node(): out.append(("loopback", f"http://127.0.0.1:{port}{path}")) if entry.get("ip"): out.append(("lan", f"http://{entry['ip']}:{port}{path}")) if node: out.append(("tailscale", f"http://{node}.maclustr.io:{port}{path}")) if entry.get("domain"): out.append(("public", f"https://{entry['domain']}{path}")) return out _route_cache: dict[str, tuple[float, str, str]] = {} # service -> (ts, route, url) ROUTE_TTL_SECONDS = 600 def _reachable(url: str) -> bool: """L'app répond-elle par cette route ? (toute réponse HTTP < 500 compte).""" base = url.rsplit("/api/", 1)[0] try: import httpx r = httpx.get(f"{base}/api/stats", params={"syncs_since_h": 1}, timeout=4.0, follow_redirects=True) return r.status_code < 500 except Exception: return False def resolve_source_url(service: str, env_url: str = "") -> str: """URL de collecte du service : registre d'abord (première route joignable, mémorisée 10 min), .env en repli.""" app = SERVICE_APP.get(service) entry = registry_apps().get(app) if app else None if not entry or not entry.get("port"): return env_url path = urlsplit(env_url).path if env_url else "" path = path or DEFAULT_PATH.get(service, "/") cached = _route_cache.get(service) now = time.time() if cached and now - cached[0] < ROUTE_TTL_SECONDS and cached[2].endswith(path): return cached[2] candidates = _candidates(service, entry, path) chosen: tuple[str, str] | None = None for route, url in candidates: if _reachable(url): chosen = (route, url) break if chosen is None: # rien ne répond : on garde la route la plus locale pour que l'erreur # remonte clairement dans le collecteur / la supervision chosen = candidates[0] if candidates else ("env", env_url) ttl_ts = now - ROUTE_TTL_SECONDS + 60 # re-sonder dans 1 min else: ttl_ts = now _route_cache[service] = (ttl_ts, chosen[0], chosen[1]) if _last_logged.get(service) != chosen[1]: _last_logged[service] = chosen[1] print(f"[ka-registry] {service} → {chosen[1]} (route {chosen[0]}, {app} sur {entry.get('node')})", flush=True) return chosen[1]