registre : diffusion aux abonnés (subscribers.json, push à chaque save, mld subscribers / registry --push)
Les consommateurs (admin-ka, gardiens ka2/4/6) lisent le registre au lieu de coder les emplacements des apps en dur. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
3 changed files +143 −3
modified
README.md
+20 −0
@@ -28,6 +28,7 @@ laptop ──ssh──▶ M1M32 (passerelle : spbgit + mld + staging ~/dispatch/ | |||||
| 28 | | `mld move <app> [--to X] [--from Y]` | migration : pré-copie à chaud, arrêt, delta final, déploiement, santé, retrait (rollback auto) | | 28 | | `mld move <app> [--to X] [--from Y]` | migration : pré-copie à chaud, arrêt, delta final, déploiement, santé, retrait (rollback auto) | | ||
| 29 | | `mld stop/start/restart <app>` · `mld logs <app>` · `mld health <app>` | cycle de vie | | 29 | | `mld stop/start/restart <app>` · `mld logs <app>` · `mld health <app>` | cycle de vie | | ||
| 30 | | `mld status [--live]` · `mld registry` | registre (`~/dispatch/registry.json` sur M1M32) | | 30 | | `mld status [--live]` · `mld registry` | registre (`~/dispatch/registry.json` sur M1M32) | | ||
| 31 | +| `mld subscribers [--add app\|node NOM CHEMIN] [--remove …]` · `mld registry --push` | abonnés du registre (copie poussée à chaque sauvegarde) · rediffusion manuelle | | ||||
| 31 | | `mld retire <app>` | arrête et efface la copie du nœud | | 32 | | `mld retire <app>` | arrête et efface la copie du nœud | | ||
| 32 | | `mld prepare <nœud> [--ka-helpers]` | installe node/pnpm/pm2/ngrok/python via Homebrew, config ngrok, pm2 startup, marqueur ; `--ka-helpers` = pousseur Ka + alias `gitsrv` + clé autorisée sur la passerelle | | 33 | | `mld prepare <nœud> [--ka-helpers]` | installe node/pnpm/pm2/ngrok/python via Homebrew, config ngrok, pm2 startup, marqueur ; `--ka-helpers` = pousseur Ka + alias `gitsrv` + clé autorisée sur la passerelle | | ||
| 33 | | `mld bootstrap <nœud>` | Xcode CLT + Homebrew sur un Mac vierge (sudo NOPASSWD temporaire) | | 34 | | `mld bootstrap <nœud>` | Xcode CLT + Homebrew sur un Mac vierge (sudo NOPASSWD temporaire) | | ||
@@ -48,6 +49,25 @@ Gabarits : `{{HOME}}`, `{{NODE}}`, `{{LAN_IP}}`, `{{IP:<alias>}}`. | |||||
| 48 | (+0.15 prefer, +0.05 si déjà sur ce nœud)`. Inéligible : hors ligne, rôle gateway (sauf épingle), | 49 | (+0.15 prefer, +0.05 si déjà sur ce nœud)`. Inéligible : hors ligne, rôle gateway (sauf épingle), | ||
| 49 | runtime manquant, RAM libre < besoin + 2 Go, disque, port occupé (hors nœud d'origine). | 50 | runtime manquant, RAM libre < besoin + 2 Go, disque, port occupé (hors nœud d'origine). | ||
| 50 | 51 | ||||
| 52 | +## Registre = source de vérité des emplacements (consommateurs) | ||||
| 53 | + | ||||
| 54 | +`~/dispatch/registry.json` (M1M32) est la **seule** référence « quelle app tourne sur quel nœud ». | ||||
| 55 | +Personne ne code plus d'emplacement en dur : les consommateurs lisent le registre. | ||||
| 56 | + | ||||
| 57 | +- **Push** : à chaque sauvegarde (deploy/move/stop/start/retire), `registry.save()` pousse une copie | ||||
| 58 | + atomique chez chaque abonné de `~/dispatch/subscribers.json` (`mld subscribers` pour lister/ajouter). | ||||
| 59 | + Un abonné `{"app": X, "path": P}` est résolu dans le registre lui-même (nœud + dir de X) — il suit | ||||
| 60 | + les migrations. Abonnés au 2026-09-04 : `admin-ka` → `data/registry.json` (console | ||||
| 61 | + administration-ka.com : sites, projets Claude Code, sweep des nœuds, CLAUDE.md de l'orchestrateur | ||||
| 62 | + multi-sites) et `ka2` → `~/ka-guardian-spool/registry.json` (gardiens ka2/ka4/ka6 : nœud, dir, | ||||
| 63 | + port, process PM2 de chaque service surveillé). | ||||
| 64 | +- **Pull** : tout nœud peut aussi tirer `ssh gitsrv cat ~/dispatch/registry.json` (alias `gitsrv` | ||||
| 65 | + = M1M32, clé `ka_guardian_ed25519` autorisée). admin-ka le fait toutes les 2 min, le service launchd | ||||
| 66 | + `com.ka.registry-sync` de M4M36 toutes les 2 min (chaîne zsh pure : macOS 26 LNP interdit le LAN aux | ||||
| 67 | + descendants de python). | ||||
| 68 | +- Champs consommés : `node`, `ip`, `port`, `domain`, `dir`, `processes` (PM2), `launchd`, `status`, | ||||
| 69 | + `updated`. Garder ces noms stables. | ||||
| 70 | + | ||||
| 51 | ## Ajouter un nœud | 71 | ## Ajouter un nœud | ||
| 52 | 72 | ||||
| 53 | 1. Activer *Session à distance* sur le Mac, autoriser la clé de la passerelle | 73 | 1. Activer *Session à distance* sur le Mac, autoriser la clé de la passerelle | ||
modified
mld/cli.py
+29 −1
@@ -148,9 +148,36 @@ def cmd_health(a): | |||||
| 148 | 148 | ||||
| 149 | 149 | ||||
| 150 | def cmd_registry(a): | 150 | def cmd_registry(a): | ||
| 151 | + if a.push: | ||||
| 152 | + res = registry.push_to_subscribers() | ||||
| 153 | + bad = [x for x in res if not x[1]] | ||||
| 154 | + sys.exit(1 if bad else 0) | ||||
| 151 | print(json.dumps(registry.load(), indent=2, ensure_ascii=False)) | 155 | print(json.dumps(registry.load(), indent=2, ensure_ascii=False)) | ||
| 152 | 156 | ||||
| 153 | 157 | ||||
| 158 | +def cmd_subscribers(a): | ||||
| 159 | + subs = registry.load_subscribers() | ||||
| 160 | + if a.add: | ||||
| 161 | + kind, target, path = a.add | ||||
| 162 | + if kind not in ("app", "node"): | ||||
| 163 | + raise SystemExit("usage : mld subscribers --add app|node <nom> <chemin>") | ||||
| 164 | + subs = [s for s in subs if not (s.get(kind) == target and s.get("path") == path)] | ||||
| 165 | + subs.append({kind: target, "path": path}) | ||||
| 166 | + registry.save_subscribers(subs) | ||||
| 167 | + if a.remove: | ||||
| 168 | + kind, target = a.remove | ||||
| 169 | + subs = [s for s in subs if s.get(kind) != target] | ||||
| 170 | + registry.save_subscribers(subs) | ||||
| 171 | + r = registry.load() | ||||
| 172 | + print("%-22s %-8s %-15s %s" % ("abonné", "nœud", "ip", "chemin")) | ||||
| 173 | + for s in subs: | ||||
| 174 | + try: | ||||
| 175 | + label, node, ip, path = registry.resolve_subscriber(s, r) | ||||
| 176 | + print("%-22s %-8s %-15s %s" % (label, node, ip, path)) | ||||
| 177 | + except ValueError as e: | ||||
| 178 | + print("%-22s %-8s %-15s %s" % (s.get("app") or s.get("node"), "—", "—", "(%s)" % e)) | ||||
| 179 | + | ||||
| 180 | + | ||||
| 154 | def cmd_import(a): | 181 | def cmd_import(a): | ||
| 155 | from . import importer | 182 | from . import importer | ||
| 156 | for p in importer.import_node(a.node, apps_filter=a.apps or None): | 183 | for p in importer.import_node(a.node, apps_filter=a.apps or None): | ||
@@ -196,7 +223,8 @@ def main(argv=None): | |||||
| 196 | s = sp.add_parser("status"); s.add_argument("app", nargs="?"); s.add_argument("--live", action="store_true"); s.set_defaults(f=cmd_status) | 223 | s = sp.add_parser("status"); s.add_argument("app", nargs="?"); s.add_argument("--live", action="store_true"); s.set_defaults(f=cmd_status) | ||
| 197 | s = sp.add_parser("health"); s.add_argument("app"); s.add_argument("--node"); s.set_defaults(f=cmd_health) | 224 | s = sp.add_parser("health"); s.add_argument("app"); s.add_argument("--node"); s.set_defaults(f=cmd_health) | ||
| 198 | s = sp.add_parser("logs"); s.add_argument("app"); s.add_argument("--node"); s.add_argument("--lines", type=int, default=40); s.set_defaults(f=cmd_logs) | 225 | s = sp.add_parser("logs"); s.add_argument("app"); s.add_argument("--node"); s.add_argument("--lines", type=int, default=40); s.set_defaults(f=cmd_logs) | ||
| 199 | − s = sp.add_parser("registry", help="registre JSON complet"); s.set_defaults(f=cmd_registry) | 226 | + s = sp.add_parser("registry", help="registre JSON complet (--push : rediffuse aux abonnés)"); s.add_argument("--push", action="store_true"); s.set_defaults(f=cmd_registry) | ||
| 227 | + s = sp.add_parser("subscribers", help="abonnés du registre (copie poussée à chaque sauvegarde)"); s.add_argument("--add", nargs=3, metavar=("app|node", "NOM", "CHEMIN")); s.add_argument("--remove", nargs=2, metavar=("app|node", "NOM")); s.set_defaults(f=cmd_subscribers) | ||||
| 200 | s = sp.add_parser("import", help="brouillons de manifestes depuis les PM2 d'un nœud"); s.add_argument("node"); s.add_argument("apps", nargs="*"); s.set_defaults(f=cmd_import) | 228 | s = sp.add_parser("import", help="brouillons de manifestes depuis les PM2 d'un nœud"); s.add_argument("node"); s.add_argument("apps", nargs="*"); s.set_defaults(f=cmd_import) | ||
| 201 | s = sp.add_parser("prepare", help="installe runtimes/ngrok/pm2-startup sur un nœud"); s.add_argument("node"); s.add_argument("--runtime", action="append"); s.add_argument("--ka-helpers", action="store_true"); s.set_defaults(f=cmd_prepare) | 229 | s = sp.add_parser("prepare", help="installe runtimes/ngrok/pm2-startup sur un nœud"); s.add_argument("node"); s.add_argument("--runtime", action="append"); s.add_argument("--ka-helpers", action="store_true"); s.set_defaults(f=cmd_prepare) | ||
| 202 | s = sp.add_parser("bootstrap", help="Xcode CLT + Homebrew sur un nœud vierge (long)"); s.add_argument("node"); s.set_defaults(f=cmd_bootstrap) | 230 | s = sp.add_parser("bootstrap", help="Xcode CLT + Homebrew sur un nœud vierge (long)"); s.add_argument("node"); s.set_defaults(f=cmd_bootstrap) | ||
modified
mld/registry.py
+94 −2
@@ -1,9 +1,27 @@ | |||||
| 1 | −"""Registre des déploiements (source de vérité) : ~/dispatch/registry.json.""" | 1 | +"""Registre des déploiements (source de vérité) : ~/dispatch/registry.json. | ||
| 2 | + | ||||
| 3 | +Diffusion (2026-09-04) : chaque sauvegarde POUSSE une copie du registre aux | ||||
| 4 | +abonnés déclarés dans ~/dispatch/subscribers.json, pour que les consommateurs | ||||
| 5 | +(console admin-ka, gardiens ka2/ka4/ka6…) connaissent TOUJOURS l'emplacement | ||||
| 6 | +réel des apps sans rien coder en dur. Un abonné est un dict : | ||||
| 7 | + | ||||
| 8 | + {"app": "admin-ka", "path": "data/registry.json"} # path relatif au dir de l'app | ||||
| 9 | + {"app": "ka2", "path": "~/ka-guardian-spool/registry.json"} | ||||
| 10 | + {"node": "M4M36", "path": "~/x/registry.json"} # ou un nœud fixe | ||||
| 11 | + | ||||
| 12 | +« app » est résolu dans le registre lui-même (nœud + dir) : l'abonnement suit | ||||
| 13 | +les migrations. Le push est best-effort (les consommateurs re-tirent aussi | ||||
| 14 | +périodiquement avec `ssh gitsrv cat ~/dispatch/registry.json`). | ||||
| 15 | +""" | ||||
| 2 | import json | 16 | import json | ||
| 3 | import os | 17 | import os | ||
| 18 | +import shlex | ||||
| 19 | +import sys | ||||
| 4 | import time | 20 | import time | ||
| 5 | from . import config | 21 | from . import config | ||
| 6 | 22 | ||||
| 23 | +SUBSCRIBERS = os.path.join(config.STATE, "subscribers.json") | ||||
| 24 | + | ||||
| 7 | 25 | ||||
| 8 | def load(): | 26 | def load(): | ||
| 9 | if os.path.exists(config.REGISTRY): | 27 | if os.path.exists(config.REGISTRY): | ||
@@ -11,12 +29,17 @@ def load(): | |||||
| 11 | return {"updated": None, "gateway": config.GATEWAY, "apps": {}, "history": []} | 29 | return {"updated": None, "gateway": config.GATEWAY, "apps": {}, "history": []} | ||
| 12 | 30 | ||||
| 13 | 31 | ||||
| 14 | −def save(r): | 32 | +def save(r, push=True): | ||
| 15 | r["updated"] = time.strftime("%Y-%m-%dT%H:%M:%S") | 33 | r["updated"] = time.strftime("%Y-%m-%dT%H:%M:%S") | ||
| 16 | r["gateway"] = config.GATEWAY | 34 | r["gateway"] = config.GATEWAY | ||
| 17 | tmp = config.REGISTRY + ".tmp" | 35 | tmp = config.REGISTRY + ".tmp" | ||
| 18 | json.dump(r, open(tmp, "w"), indent=2, ensure_ascii=False) | 36 | json.dump(r, open(tmp, "w"), indent=2, ensure_ascii=False) | ||
| 19 | os.replace(tmp, config.REGISTRY) | 37 | os.replace(tmp, config.REGISTRY) | ||
| 38 | + if push: | ||||
| 39 | + try: | ||||
| 40 | + push_to_subscribers(r) | ||||
| 41 | + except Exception as e: # jamais bloquant pour un déploiement | ||||
| 42 | + print(" registre : diffusion aux abonnés échouée (%s)" % e, file=sys.stderr) | ||||
| 20 | 43 | ||||
| 21 | 44 | ||||
| 22 | def node_of(app): | 45 | def node_of(app): | ||
@@ -49,3 +72,72 @@ def log(app, event, **kw): | |||||
| 49 | r["history"].append(dict(ts=time.strftime("%Y-%m-%dT%H:%M:%S"), app=app, event=event, **kw)) | 72 | r["history"].append(dict(ts=time.strftime("%Y-%m-%dT%H:%M:%S"), app=app, event=event, **kw)) | ||
| 50 | r["history"] = r["history"][-500:] | 73 | r["history"] = r["history"][-500:] | ||
| 51 | save(r) | 74 | save(r) | ||
| 75 | + | ||||
| 76 | + | ||||
| 77 | +# ----------------------------------------------------------------- abonnés --- | ||||
| 78 | + | ||||
| 79 | +def load_subscribers(): | ||||
| 80 | + if os.path.exists(SUBSCRIBERS): | ||||
| 81 | + return json.load(open(SUBSCRIBERS)) | ||||
| 82 | + return [] | ||||
| 83 | + | ||||
| 84 | + | ||||
| 85 | +def save_subscribers(subs): | ||||
| 86 | + tmp = SUBSCRIBERS + ".tmp" | ||||
| 87 | + json.dump(subs, open(tmp, "w"), indent=2, ensure_ascii=False) | ||||
| 88 | + os.replace(tmp, SUBSCRIBERS) | ||||
| 89 | + | ||||
| 90 | + | ||||
| 91 | +def _abs(p): | ||||
| 92 | + return p.replace("~", "/Users/%s" % config.USER, 1) if p.startswith("~") else p | ||||
| 93 | + | ||||
| 94 | + | ||||
| 95 | +def resolve_subscriber(sub, r): | ||||
| 96 | + """→ (label, node, ip, chemin absolu) ou lève ValueError.""" | ||||
| 97 | + from . import nodes | ||||
| 98 | + apps = r.get("apps", {}) | ||||
| 99 | + if sub.get("app"): | ||||
| 100 | + entry = apps.get(sub["app"]) | ||||
| 101 | + if not entry or not entry.get("node"): | ||||
| 102 | + raise ValueError("app %s absente du registre" % sub["app"]) | ||||
| 103 | + node = entry["node"] | ||||
| 104 | + ip = entry.get("ip") or nodes.ip_of(node, rediscover=False) | ||||
| 105 | + base = entry.get("dir") or "~" | ||||
| 106 | + label = "%s@%s" % (sub["app"], node) | ||||
| 107 | + else: | ||||
| 108 | + node = sub["node"] | ||||
| 109 | + ip = nodes.ip_of(node, rediscover=False) | ||||
| 110 | + base = "~" | ||||
| 111 | + label = node | ||||
| 112 | + if not ip: | ||||
| 113 | + raise ValueError("IP inconnue pour %s" % node) | ||||
| 114 | + path = sub["path"] | ||||
| 115 | + if not (path.startswith("/") or path.startswith("~")): | ||||
| 116 | + path = base.rstrip("/") + "/" + path | ||||
| 117 | + return label, node, ip, _abs(path) | ||||
| 118 | + | ||||
| 119 | + | ||||
| 120 | +def push_to_subscribers(r=None, verbose=True): | ||||
| 121 | + """Copie atomique du registre chez chaque abonné (ssh LAN, 12 s max chacun).""" | ||||
| 122 | + from . import ssh | ||||
| 123 | + r = r or load() | ||||
| 124 | + subs = load_subscribers() | ||||
| 125 | + results = [] | ||||
| 126 | + payload = json.dumps(r, indent=2, ensure_ascii=False) | ||||
| 127 | + for sub in subs: | ||||
| 128 | + try: | ||||
| 129 | + label, node, ip, path = resolve_subscriber(sub, r) | ||||
| 130 | + except ValueError as e: | ||||
| 131 | + results.append((sub, False, str(e))) | ||||
| 132 | + if verbose: | ||||
| 133 | + print(" registre → %s : ignoré (%s)" % (sub.get("app") or sub.get("node"), e)) | ||||
| 134 | + continue | ||||
| 135 | + q = shlex.quote(path) | ||||
| 136 | + script = ("mkdir -p $(dirname %s) && cat > %s.tmp <<'__MLD_REG__'\n%s\n__MLD_REG__\n" | ||||
| 137 | + "mv -f %s.tmp %s && echo ok" % (q, q, payload, q, q)) | ||||
| 138 | + rc, out, err = ssh.run(ip, script, timeout=12, path=False) | ||||
| 139 | + ok = rc == 0 and "ok" in out | ||||
| 140 | + results.append((sub, ok, (err or out).strip()[:120])) | ||||
| 141 | + if verbose: | ||||
| 142 | + print(" registre → %s:%s %s" % (label, path, "ok" if ok else "ÉCHEC " + (err or out).strip()[:80])) | ||||
| 143 | + return results | ||||
| 52 | 144 | ||||