SPB Git forge

spb/api-ka

Public

API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com

48commits 1branches 0releases
5.9 MBsize
maindefault branch
19 days agolast push
Python 60.9% HTML 21% TypeScript 7.3% JavaScript 5.2% CSS 4.8% Shell 0.8%
6.1 KB · 156 lines python
Raw Blame History
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