SPB Git

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

URL des apps sœurs résolues depuis le registre maclustr-dispatch (loopback → LAN → Tailscale → public), plus d'emplacements figés dans le .env — corrige la vague de 421 connecteurs lou-ka « stale » après les migrations mld du 2026-09-04

Simon-Pierre Boucher committed (Sep 5, 2026) parent 719c745 Browse files

5 changed files +174 −5

modified src/api/routes/services.py +3 −1
@@ -204,7 +204,9 @@ def louka_fairvalue(uid: str = Path(..., description="uid d'annonce lou-ka")):
204 204
205 import httpx 205 import httpx
206 206
207− base = (os.environ.get("LOUKA_SOURCE_URL") 207+ from src.config import source_url as _live_source_url
208+
209+ base = (_live_source_url("louka") or os.environ.get("LOUKA_SOURCE_URL")
208 or "http://127.0.0.1:8095/api/listings").rsplit("/api/", 1)[0] 210 or "http://127.0.0.1:8095/api/listings").rsplit("/api/", 1)[0]
209 try: 211 try:
210 resp = httpx.get(f"{base}/api/fairvalue/{uid}", timeout=10) 212 resp = httpx.get(f"{base}/api/fairvalue/{uid}", timeout=10)
modified src/collectors/base_collector.py +3 −1
@@ -72,7 +72,9 @@ class BaseCollector(abc.ABC):
72 @property 72 @property
73 def source_url(self) -> str: 73 def source_url(self) -> str:
74 """URL source du service, lue depuis le .env.""" 74 """URL source du service, lue depuis le .env."""
75− url = get_settings().source_urls.get(self.service, "") 75+ from src.config import source_url as _live_source_url
76+
77+ url = _live_source_url(self.service) or get_settings().source_urls.get(self.service, "")
76 if not url: 78 if not url:
77 raise RuntimeError( 79 raise RuntimeError(
78 f"Variable source manquante pour {self.service} " 80 f"Variable source manquante pour {self.service} "
modified src/config.py +9 −1
@@ -18,6 +18,8 @@ from pathlib import Path
18 18
19 from dotenv import load_dotenv 19 from dotenv import load_dotenv
20 20
21+from src.ka_registry import resolve_source_url
22+
21 # Nœud imposé (optionnel) : APIKA_REQUIRED_NODE=m3u96b ; vide = tourne sur n'importe quel nœud (placement par maclustr-dispatch). 23 # Nœud imposé (optionnel) : APIKA_REQUIRED_NODE=m3u96b ; vide = tourne sur n'importe quel nœud (placement par maclustr-dispatch).
22 REQUIRED_NODE = os.getenv("APIKA_REQUIRED_NODE", "").strip().lower() 24 REQUIRED_NODE = os.getenv("APIKA_REQUIRED_NODE", "").strip().lower()
23 25
@@ -119,10 +121,16 @@ def get_settings() -> Settings:
119 "TROUVEKA_SUGGEST_URL", "https://www.trouve-ka.com/api/suggest" 121 "TROUVEKA_SUGGEST_URL", "https://www.trouve-ka.com/api/suggest"
120 ), 122 ),
121 source_urls={ 123 source_urls={
122− service: os.getenv(env_var, "") 124+ service: resolve_source_url(service, os.getenv(env_var, ""))
123 for service, env_var in SERVICE_SOURCE_ENV.items() 125 for service, env_var in SERVICE_SOURCE_ENV.items()
124 }, 126 },
125 base_dir=BASE_DIR, 127 base_dir=BASE_DIR,
126 logs_dir=logs_dir, 128 logs_dir=logs_dir,
127 backups_dir=backups_dir, 129 backups_dir=backups_dir,
128 ) 130 )
131+
132+
133+def source_url(service: str) -> str:
134+ """URL de collecte LIVE d'un service : registre maclustr-dispatch (suit les
135+ ``mld move``), repli sur ``<SERVICE>_SOURCE_URL`` du .env."""
136+ return resolve_source_url(service, os.getenv(SERVICE_SOURCE_ENV.get(service, ""), ""))
added src/ka_registry.py +155 −0
@@ -0,0 +1,155 @@
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]
modified src/monitoring/connector_health.py +4 −2
@@ -382,8 +382,10 @@ def rotation_factor(
382 382
383 383
384 def _stats_url(service: str) -> str | None: 384 def _stats_url(service: str) -> str | None:
385− """Dérive l'URL /api/stats de l'app depuis la SOURCE_URL du .env.""" 385+ """Dérive l'URL /api/stats de l'app depuis le registre mld (repli : SOURCE_URL du .env)."""
386− source_url = get_settings().source_urls.get(service, "") 386+ from src.config import source_url as _live_source_url
387+
388+ source_url = _live_source_url(service) or get_settings().source_urls.get(service, "")
387 if not source_url: 389 if not source_url:
388 return None 390 return None
389 parts = urlsplit(source_url) 391 parts = urlsplit(source_url)
390 392
Menu

Theme