"""Registre des déploiements (source de vérité) : ~/dispatch/registry.json. Diffusion (2026-09-04) : chaque sauvegarde POUSSE une copie du registre aux abonnés déclarés dans ~/dispatch/subscribers.json, pour que les consommateurs (console admin-ka, gardiens ka2/ka4/ka6…) connaissent TOUJOURS l'emplacement réel des apps sans rien coder en dur. Un abonné est un dict : {"app": "admin-ka", "path": "data/registry.json"} # path relatif au dir de l'app {"app": "ka2", "path": "~/ka-guardian-spool/registry.json"} {"node": "M4M36", "path": "~/x/registry.json"} # ou un nœud fixe « app » est résolu dans le registre lui-même (nœud + dir) : l'abonnement suit les migrations. Le push est best-effort (les consommateurs re-tirent aussi périodiquement avec `ssh gitsrv cat ~/dispatch/registry.json`). """ import json import os import shlex import sys import time from . import config SUBSCRIBERS = os.path.join(config.STATE, "subscribers.json") def load(): if os.path.exists(config.REGISTRY): return json.load(open(config.REGISTRY)) return {"updated": None, "gateway": config.GATEWAY, "apps": {}, "history": []} def save(r, push=True): r["updated"] = time.strftime("%Y-%m-%dT%H:%M:%S") r["gateway"] = config.GATEWAY tmp = config.REGISTRY + ".tmp" json.dump(r, open(tmp, "w"), indent=2, ensure_ascii=False) os.replace(tmp, config.REGISTRY) if push: try: push_to_subscribers(r) except Exception as e: # jamais bloquant pour un déploiement print(" registre : diffusion aux abonnés échouée (%s)" % e, file=sys.stderr) def node_of(app): return load()["apps"].get(app, {}).get("node") def apps_on(alias): return [a for a, v in load()["apps"].items() if v.get("node") == alias] def set_app(app, **fields): r = load() cur = r["apps"].get(app, {}) cur.update(fields) cur["updated"] = time.strftime("%Y-%m-%dT%H:%M:%S") r["apps"][app] = cur save(r) def update_many(changes, push=True): """Met à jour plusieurs apps ({app: {champs}}) en une seule sauvegarde/diffusion (status --live, heal).""" if not changes: return r = load() now = time.strftime("%Y-%m-%dT%H:%M:%S") for app, fields in changes.items(): cur = r["apps"].get(app, {}) cur.update(fields) cur["updated"] = now r["apps"][app] = cur save(r, push=push) def remove_app(app, note=""): r = load() if app in r["apps"]: r["history"].append({"ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "app": app, "event": "removed", "was": r["apps"][app], "note": note}) del r["apps"][app] save(r) def log(app, event, **kw): r = load() r["history"].append(dict(ts=time.strftime("%Y-%m-%dT%H:%M:%S"), app=app, event=event, **kw)) r["history"] = r["history"][-500:] save(r) # ----------------------------------------------------------------- abonnés --- def load_subscribers(): if os.path.exists(SUBSCRIBERS): return json.load(open(SUBSCRIBERS)) return [] def save_subscribers(subs): tmp = SUBSCRIBERS + ".tmp" json.dump(subs, open(tmp, "w"), indent=2, ensure_ascii=False) os.replace(tmp, SUBSCRIBERS) def _abs(p, ip=None): return p.replace("~", config.home_of(ip), 1) if p.startswith("~") else p def resolve_subscriber(sub, r): """→ (label, node, ip, chemin absolu) ou lève ValueError.""" from . import nodes apps = r.get("apps", {}) if sub.get("app"): entry = apps.get(sub["app"]) if not entry or not entry.get("node"): raise ValueError("app %s absente du registre" % sub["app"]) node = entry["node"] # IP live d abord : l IP figee au deploiement devient fausse quand le DHCP renumerote le LAN (2026-09-21) ip = nodes.ip_of(node, rediscover=False) or entry.get("ip") base = entry.get("dir") or "~" label = "%s@%s" % (sub["app"], node) else: node = sub["node"] ip = nodes.ip_of(node, rediscover=False) base = "~" label = node if not ip: raise ValueError("IP inconnue pour %s" % node) path = sub["path"] if not (path.startswith("/") or path.startswith("~")): path = base.rstrip("/") + "/" + path return label, node, ip, _abs(path, ip) def push_to_subscribers(r=None, verbose=True): """Copie atomique du registre chez chaque abonné (ssh LAN, 12 s max chacun).""" from . import ssh r = r or load() subs = load_subscribers() results = [] payload = json.dumps(r, indent=2, ensure_ascii=False) for sub in subs: try: label, node, ip, path = resolve_subscriber(sub, r) except ValueError as e: results.append((sub, False, str(e))) if verbose: print(" registre → %s : ignoré (%s)" % (sub.get("app") or sub.get("node"), e)) continue q = shlex.quote(path) script = ("mkdir -p $(dirname %s) && cat > %s.tmp <<'__MLD_REG__'\n%s\n__MLD_REG__\n" "mv -f %s.tmp %s && echo ok" % (q, q, payload, q, q)) rc, out, err = ssh.run(ip, script, timeout=12, path=False) ok = rc == 0 and "ok" in out results.append((sub, ok, (err or out).strip()[:120])) if verbose: print(" registre → %s:%s %s" % (label, path, "ok" if ok else "ÉCHEC " + (err or out).strip()[:80])) return results