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%

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 19 days ago (Sep 5, 2026) parent 719c745

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 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 210 or "http://127.0.0.1:8095/api/listings").rsplit("/api/", 1)[0]
209 211 try:
210 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 72 @property
73 73 def source_url(self) -> str:
74 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 78 if not url:
77 79 raise RuntimeError(
78 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 19 from dotenv import load_dotenv
20 20
21 +from src.ka_registry import resolve_source_url
22 +
21 23 # Nœud imposé (optionnel) : APIKA_REQUIRED_NODE=m3u96b ; vide = tourne sur n'importe quel nœud (placement par maclustr-dispatch).
22 24 REQUIRED_NODE = os.getenv("APIKA_REQUIRED_NODE", "").strip().lower()
23 25
@@ -119,10 +121,16 @@ def get_settings() -> Settings:
119 121 "TROUVEKA_SUGGEST_URL", "https://www.trouve-ka.com/api/suggest"
120 122 ),
121 123 source_urls={
122 service: os.getenv(env_var, "")
124 + service: resolve_source_url(service, os.getenv(env_var, ""))
123 125 for service, env_var in SERVICE_SOURCE_ENV.items()
124 126 },
125 127 base_dir=BASE_DIR,
126 128 logs_dir=logs_dir,
127 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 384 def _stats_url(service: str) -> str | None:
385 """Dérive l'URL /api/stats de l'app depuis la SOURCE_URL du .env."""
386 source_url = get_settings().source_urls.get(service, "")
385 + """Dérive l'URL /api/stats de l'app depuis le registre mld (repli : SOURCE_URL du .env)."""
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 389 if not source_url:
388 390 return None
389 391 parts = urlsplit(source_url)
390 392