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 | 28 | | `mld move <app> [--to X] [--from Y]` | migration : pré-copie à chaud, arrêt, delta final, déploiement, santé, retrait (rollback auto) | |
| 29 | 29 | | `mld stop/start/restart <app>` · `mld logs <app>` · `mld health <app>` | cycle de vie | |
| 30 | 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 | 32 | | `mld retire <app>` | arrête et efface la copie du nœud | |
| 32 | 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 | 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 | 49 | (+0.15 prefer, +0.05 si déjà sur ce nœud)`. Inéligible : hors ligne, rôle gateway (sauf épingle), |
| 49 | 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 | 71 | ## Ajouter un nœud |
| 52 | 72 | |
| 53 | 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 | 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 | 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 | 181 | def cmd_import(a): |
| 155 | 182 | from . import importer |
| 156 | 183 | for p in importer.import_node(a.node, apps_filter=a.apps or None): |
@@ -196,7 +223,8 @@ def main(argv=None): | ||
| 196 | 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 | 224 | s = sp.add_parser("health"); s.add_argument("app"); s.add_argument("--node"); s.set_defaults(f=cmd_health) |
| 198 | 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 | 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 | 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 | 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 | 16 | import json |
| 3 | 17 | import os |
| 18 | +import shlex | |
| 19 | +import sys | |
| 4 | 20 | import time |
| 5 | 21 | from . import config |
| 6 | 22 | |
| 23 | +SUBSCRIBERS = os.path.join(config.STATE, "subscribers.json") | |
| 24 | + | |
| 7 | 25 | |
| 8 | 26 | def load(): |
| 9 | 27 | if os.path.exists(config.REGISTRY): |
@@ -11,12 +29,17 @@ def load(): | ||
| 11 | 29 | return {"updated": None, "gateway": config.GATEWAY, "apps": {}, "history": []} |
| 12 | 30 | |
| 13 | 31 | |
| 14 | −def save(r): | |
| 32 | +def save(r, push=True): | |
| 15 | 33 | r["updated"] = time.strftime("%Y-%m-%dT%H:%M:%S") |
| 16 | 34 | r["gateway"] = config.GATEWAY |
| 17 | 35 | tmp = config.REGISTRY + ".tmp" |
| 18 | 36 | json.dump(r, open(tmp, "w"), indent=2, ensure_ascii=False) |
| 19 | 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 | 45 | def node_of(app): |
@@ -49,3 +72,72 @@ def log(app, event, **kw): | ||
| 49 | 72 | r["history"].append(dict(ts=time.strftime("%Y-%m-%dT%H:%M:%S"), app=app, event=event, **kw)) |
| 50 | 73 | r["history"] = r["history"][-500:] |
| 51 | 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 | |