|
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] |