| |
1 | +# ============================================ |
| |
2 | +# Projet : API-KA |
| |
3 | +# Fichier : src/ka_registry.py |
| |
4 | +# Author : Simon-Pierre Boucher |
| |
5 | +# Contact : contact@spboucher.ai |
| |
6 | +# Date : 2026-09-04 |
| |
7 | +# ============================================ |
| |
8 | +"""Résolution des URL des apps sœurs depuis le registre maclustr-dispatch. |
| |
9 | + |
| |
10 | +Les apps Ka bougent de nœud (``mld move``) : une URL figée dans le .env |
| |
11 | +(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, |
| |
13 | +421 connecteurs lou-ka passés « stale » et 6 collectes cassées. |
| |
14 | + |
| |
15 | +Le registre (``data/registry.json``) est poussé par la passerelle M1M32 à |
| |
16 | +chaque sauvegarde (``mld subscribers``). Pour chaque service on construit |
| |
17 | +``http://<ip-ou-loopback>:<port><chemin>`` : loopback si l'app est sur le même |
| |
18 | +nœud qu'API-KA (beaucoup n'écoutent qu'en local), IP LAN sinon. Le chemin est |
| |
19 | +repris de la variable ``*_SOURCE_URL`` du .env (repli : table par défaut). |
| |
20 | +Sans registre ou sans entrée, l'URL du .env est utilisée telle quelle. |
| |
21 | +""" |
| |
22 | + |
| |
23 | +from __future__ import annotations |
| |
24 | + |
| |
25 | +import json |
| |
26 | +import os |
| |
27 | +import time |
| |
28 | +from pathlib import Path |
| |
29 | +from urllib.parse import urlsplit |
| |
30 | + |
| |
31 | +SERVICE_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 | +} |
| |
36 | + |
| |
37 | +DEFAULT_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 | +} |
| |
43 | + |
| |
44 | +_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 | +] |
| |
50 | + |
| |
51 | +_cache: dict = {"path": None, "mtime": 0.0, "checked": 0.0, "apps": {}} |
| |
52 | +_self_node: str | None = None |
| |
53 | +_last_logged: dict[str, str] = {} |
| |
54 | + |
| |
55 | + |
| |
56 | +def self_node() -> str: |
| |
57 | + global _self_node |
| |
58 | + 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_node |
| |
64 | + |
| |
65 | + |
| |
66 | +def 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"] = now |
| |
72 | + for p in _CANDIDATES: |
| |
73 | + if not p or not os.path.exists(p): |
| |
74 | + continue |
| |
75 | + try: |
| |
76 | + mtime = os.stat(p).st_mtime |
| |
77 | + 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 None |
| |
82 | + if isinstance(apps, dict): |
| |
83 | + _cache.update({"path": p, "mtime": mtime, "apps": apps}) |
| |
84 | + return apps |
| |
85 | + except (OSError, ValueError): |
| |
86 | + continue |
| |
87 | + return _cache["apps"] |
| |
88 | + |
| |
89 | + |
| |
90 | +def _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 public |
| |
93 | + (ngrok). Sur macOS 26, un python Homebrew lancé sans session graphique se |
| |
94 | + 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 out |
| |
108 | + |
| |
109 | + |
| |
110 | +_route_cache: dict[str, tuple[float, str, str]] = {} # service -> (ts, route, url) |
| |
111 | +ROUTE_TTL_SECONDS = 600 |
| |
112 | + |
| |
113 | + |
| |
114 | +def _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 httpx |
| |
119 | + r = httpx.get(f"{base}/api/stats", params={"syncs_since_h": 1}, timeout=4.0, follow_redirects=True) |
| |
120 | + return r.status_code < 500 |
| |
121 | + except Exception: |
| |
122 | + return False |
| |
123 | + |
| |
124 | + |
| |
125 | +def 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 None |
| |
130 | + if not entry or not entry.get("port"): |
| |
131 | + return env_url |
| |
132 | + 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 = None |
| |
140 | + for route, url in candidates: |
| |
141 | + if _reachable(url): |
| |
142 | + chosen = (route, url) |
| |
143 | + break |
| |
144 | + if chosen is None: |
| |
145 | + # rien ne répond : on garde la route la plus locale pour que l'erreur |
| |
146 | + # remonte clairement dans le collecteur / la supervision |
| |
147 | + chosen = candidates[0] if candidates else ("env", env_url) |
| |
148 | + ttl_ts = now - ROUTE_TTL_SECONDS + 60 # re-sonder dans 1 min |
| |
149 | + else: |
| |
150 | + ttl_ts = now |
| |
151 | + _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] |