SPB Git forge
29commits 1branches 0releases
684.0 KBsize
maindefault branch
3 days agolast push
Python 95.8% Shell 4.2%

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 20 days ago (Sep 4, 2026) parent 23b4ab4

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