SPB Git

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>
Simon-Pierre Boucher committed (Sep 4, 2026) parent 23b4ab4 Browse files

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
Menu

Theme