API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com
Python 60.9%
HTML 21%
TypeScript 7.3%
JavaScript 5.2%
CSS 4.8%
Shell 0.8%
1# ============================================2# Projet : API-KA3# Fichier : src/ka_registry.py4# Author : Simon-Pierre Boucher5# Contact : contact@spboucher.ai6# Date : 2026-09-047# ============================================8"""Résolution des URL des apps sœurs depuis le registre maclustr-dispatch.910Les apps Ka bougent de nœud (``mld move``) : une URL figée dans le .env11(127.0.0.1, nom mDNS d'un nœud) devient fausse — incident du 2026-09-04 :12``LOUKA_SOURCE_URL=127.0.0.1:8095`` pointait sur auto-ka après la migration,13421 connecteurs lou-ka passés « stale » et 6 collectes cassées.1415Le registre (``data/registry.json``) est poussé par la passerelle M1M32 à16chaque sauvegarde (``mld subscribers``). Pour chaque service on construit17``http://<ip-ou-loopback>:<port><chemin>`` : loopback si l'app est sur le même18nœud qu'API-KA (beaucoup n'écoutent qu'en local), IP LAN sinon. Le chemin est19repris de la variable ``*_SOURCE_URL`` du .env (repli : table par défaut).20Sans registre ou sans entrée, l'URL du .env est utilisée telle quelle.21"""2223from __future__ import annotations2425import json26import os27import time28from pathlib import Path29from urllib.parse import urlsplit3031SERVICE_APP: dict[str, str] = {32 "louka": "lou-ka", "immoka": "immo-ka", "foodka": "food-ka", "autoka": "auto-ka",33 "fabrika": "fabri-ka", "restoka": "resto-ka", "sortika": "sorti-ka", "creaka": "crea-ka",34 "jobka": "job-ka", "houseka": "house-ka", "rentka": "rent-ka",35}3637DEFAULT_PATH: dict[str, str] = {38 "louka": "/api/listings", "immoka": "/api/listings", "houseka": "/api/listings",39 "rentka": "/api/listings", "foodka": "/api/products", "fabrika": "/api/products",40 "autoka": "/api/vehicles", "restoka": "/api/restaurants", "sortika": "/api/events",41 "creaka": "/api/creators", "jobka": "/api/jobs",42}4344_BASE = Path(__file__).resolve().parents[1]45_CANDIDATES = [46 os.getenv("KA_REGISTRY_PATH", ""),47 str(_BASE / "data" / "registry.json"),48 str(Path.home() / "dispatch" / "registry.json"),49]5051_cache: dict = {"path": None, "mtime": 0.0, "checked": 0.0, "apps": {}}52_self_node: str | None = None53_last_logged: dict[str, str] = {}545556def self_node() -> str:57 global _self_node58 if _self_node is None:59 try:60 _self_node = (Path.home() / ".maclustr-node").read_text().strip()61 except OSError:62 _self_node = os.getenv("NODE_NAME", "")63 return _self_node646566def registry_apps() -> dict:67 """Entrées ``apps`` du registre, rechargées si le fichier a changé (≤ 1 stat/10 s)."""68 now = time.time()69 if now - _cache["checked"] < 10:70 return _cache["apps"]71 _cache["checked"] = now72 for p in _CANDIDATES:73 if not p or not os.path.exists(p):74 continue75 try:76 mtime = os.stat(p).st_mtime77 if p == _cache["path"] and mtime <= _cache["mtime"]:78 return _cache["apps"]79 with open(p) as f:80 data = json.load(f)81 apps = data.get("apps") if isinstance(data, dict) else None82 if isinstance(apps, dict):83 _cache.update({"path": p, "mtime": mtime, "apps": apps})84 return apps85 except (OSError, ValueError):86 continue87 return _cache["apps"]888990def _candidates(service: str, entry: dict, path: str) -> list[tuple[str, str]]:91 """Routes possibles vers l'app, de la plus locale à la plus publique :92 loopback (même nœud) → IP LAN → nom Tailscale du nœud → domaine public93 (ngrok). Sur macOS 26, un python Homebrew lancé sans session graphique se94 voit refuser le LAN et Tailscale (« Local Network Privacy », Errno 65) :95 le domaine public reste alors la seule route — d'où le repli automatique."""96 node = entry.get("node") or ""97 port = entry["port"]98 out: list[tuple[str, str]] = []99 if node and node == self_node():100 out.append(("loopback", f"http://127.0.0.1:{port}{path}"))101 if entry.get("ip"):102 out.append(("lan", f"http://{entry['ip']}:{port}{path}"))103 if node:104 out.append(("tailscale", f"http://{node}.maclustr.io:{port}{path}"))105 if entry.get("domain"):106 out.append(("public", f"https://{entry['domain']}{path}"))107 return out108109110_route_cache: dict[str, tuple[float, str, str]] = {} # service -> (ts, route, url)111ROUTE_TTL_SECONDS = 600112113114def _reachable(url: str) -> bool:115 """L'app répond-elle par cette route ? (toute réponse HTTP < 500 compte)."""116 base = url.rsplit("/api/", 1)[0]117 try:118 import httpx119 r = httpx.get(f"{base}/api/stats", params={"syncs_since_h": 1}, timeout=4.0, follow_redirects=True)120 return r.status_code < 500121 except Exception:122 return False123124125def resolve_source_url(service: str, env_url: str = "") -> str:126 """URL de collecte du service : registre d'abord (première route joignable,127 mémorisée 10 min), .env en repli."""128 app = SERVICE_APP.get(service)129 entry = registry_apps().get(app) if app else None130 if not entry or not entry.get("port"):131 return env_url132 path = urlsplit(env_url).path if env_url else ""133 path = path or DEFAULT_PATH.get(service, "/")134 cached = _route_cache.get(service)135 now = time.time()136 if cached and now - cached[0] < ROUTE_TTL_SECONDS and cached[2].endswith(path):137 return cached[2]138 candidates = _candidates(service, entry, path)139 chosen: tuple[str, str] | None = None140 for route, url in candidates:141 if _reachable(url):142 chosen = (route, url)143 break144 if chosen is None:145 # rien ne répond : on garde la route la plus locale pour que l'erreur146 # remonte clairement dans le collecteur / la supervision147 chosen = candidates[0] if candidates else ("env", env_url)148 ttl_ts = now - ROUTE_TTL_SECONDS + 60 # re-sonder dans 1 min149 else:150 ttl_ts = now151 _route_cache[service] = (ttl_ts, chosen[0], chosen[1])152 if _last_logged.get(service) != chosen[1]:153 _last_logged[service] = chosen[1]154 print(f"[ka-registry] {service} → {chosen[1]} (route {chosen[0]}, {app} sur {entry.get('node')})", flush=True)155 return chosen[1]156