SPB Git

maclustr-dispatch v1.0.0 — mld : découverte LAN, scan, picker, deploy/move/health, prepare/bootstrap, manifestes (2026-09-04)

Simon-Pierre Boucher committed (Sep 4, 2026) Browse files

18 changed files +1,529 −0

added .gitignore +3 −0
@@ -0,0 +1,3 @@
1+__pycache__/
2+*.pyc
3+.DS_Store
added README.md +65 −0
@@ -0,0 +1,65 @@
1+# maclustr-dispatch — `mld`
2+
3+Orchestrateur de déploiement du cluster MacLustr. Il tourne **sur la passerelle M1M32** et
4+déploie chaque application sur le **nœud le plus efficient** du cluster, via le LAN
5+(192.168.2.x, clé SSH `maclustr-m1m32` autorisée sur tous les nœuds).
6+
7+```
8+laptop ──ssh──▶ M1M32 (passerelle : spbgit + mld + staging ~/dispatch/stage)
9+ │ scan live (RAM libre, charge, disque, ports, runtimes)
10+ │ choix du nœud (score) → rsync LAN → PM2/launchd → ngrok → healthcheck → registre
11+ ▼
12+ M3U96a · M3U96b · M2U64 · M4M64a · M4M64b · M4M36 · M2M32 · …
13+```
14+
15+## Depuis le laptop
16+
17+`~/Desktop/cluster-skill/mld <commande>` relaie à la passerelle et rapatrie le registre
18+(`cluster-skill/cluster-registry.json`).
19+
20+| Commande | Effet |
21+|---|---|
22+| `mld scan` / `mld nodes` | sonde (ou relit) ressources + runtimes des nœuds |
23+| `mld apps` / `mld show <app>` | manifestes et emplacement courant |
24+| `mld pick <app>` · `mld pick --ram 4 --runtime node` | classement des nœuds pour une app / un besoin |
25+| `mld plan [apps…]` | plan de placement global (glouton, projection des ressources) |
26+| `mld stage <dir-local> <app>` | pousse un dossier du laptop dans le staging de la passerelle |
27+| `mld deploy <app> [--node X]` | staging → nœud choisi (auto sans `--node`), démarrage, santé, registre |
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 |
30+| `mld status [--live]` · `mld registry` | registre (`~/dispatch/registry.json` sur M1M32) |
31+| `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 bootstrap <nœud>` | Xcode CLT + Homebrew sur un Mac vierge (sudo NOPASSWD temporaire) |
34+| `mld import <nœud>` | brouillons de manifestes depuis les PM2 vivants d'un nœud (`~/dispatch/apps/_drafts/`) |
35+
36+## Manifestes (`~/dispatch/apps/<app>.json`, mode 600)
37+
38+Un manifeste décrit *comment* tourne une app : `dir` (même chemin absolu sur tous les nœuds),
39+`processes` (PM2 : script, args, interpreter, cwd, env, cron_restart…), `ngrok` (domaine réservé →
40+processus `<app>-ngrok`), `launchd` (plists générés), `requires` (runtimes, RAM, ports),
41+`placement` (`pin`, `prefer`, `avoid` + raison), `sync_excludes`, `extra_paths`, `env_overrides`
42+(réécriture de `.env` au déploiement), `hooks.post_sync` / `post_start`, `ka_repo` (inscription au pousseur).
43+Gabarits : `{{HOME}}`, `{{NODE}}`, `{{LAN_IP}}`, `{{IP:<alias>}}`.
44+
45+## Scoring
46+
47+`0.45·RAM libre après placement + 0.25·cœurs idle + 0.10·disque + 0.15·cœurs − 0.04·apps hébergées
48+(+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+
51+## Ajouter un nœud
52+
53+1. Activer *Session à distance* sur le Mac, autoriser la clé de la passerelle
54+ (`~/.ssh/id_ed25519.pub` de M1M32) et celle du laptop dans `~/.ssh/authorized_keys`.
55+2. `echo <alias> > ~/.maclustr-node` sur le nœud ; ajouter l'alias dans `mld/config.py` (`NODES`).
56+3. `mld discover` → `mld bootstrap <alias>` (si Mac vierge) → `mld prepare <alias>`.
57+
58+## Fichiers
59+
60+- `mld/config.py` inventaire statique, rôles, chemins, détection des runtimes
61+- `mld/nodes.py` découverte LAN, sonde · `mld/picker.py` score/plan · `mld/deploy.py` fetch/deploy/move/health
62+- `mld/render.py` ecosystem PM2 + plists · `mld/prepare.py` runtimes/bootstrap/ka-helpers · `mld/importer.py`
63+- `assets/` pousseur Ka (script + plist)
64+
65+Pas de dépendance : Python 3 système (3.9) + ssh/rsync (GNU rsync via Homebrew sur la passerelle).
added assets/com.ka.pousseur.plist +12 −0
@@ -0,0 +1,12 @@
1+<?xml version="1.0" encoding="UTF-8"?>
2+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+<plist version="1.0"><dict>
4+ <key>Label</key><string>com.ka.pousseur</string>
5+ <key>ProgramArguments</key><array>
6+ <string>/bin/zsh</string>
7+ <string>/Users/simon-pierreboucher/ka-pousseur.sh</string>
8+ </array>
9+ <key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
10+ <key>StandardOutPath</key><string>/Users/simon-pierreboucher/ka-guardian-spool/pousseur.log</string>
11+ <key>StandardErrorPath</key><string>/Users/simon-pierreboucher/ka-guardian-spool/pousseur.log</string>
12+</dict></plist>
added assets/ka-pousseur.sh +34 −0
@@ -0,0 +1,34 @@
1+#!/bin/zsh
2+# ============================================
3+# KA pousseur — pousse vers spbgit les commits en attente des repos Ka du nœud.
4+# Chaîne 100 % binaires Apple (launchd → zsh → /usr/bin/git → /usr/bin/ssh) :
5+# contourne macOS 26 Local Network Privacy, qui bloque les `git push` lancés
6+# par les missions Claude (descendants du runner python homebrew).
7+# Repos surveillés : un chemin par ligne dans ~/.ka-pousseur-repos.
8+# Push SIMPLE uniquement (jamais de force) : un non-fast-forward est loggé,
9+# jamais résolu d'autorité.
10+# ============================================
11+export PATH=/usr/bin:/bin:/usr/sbin:/sbin
12+SPOOL=~/ka-guardian-spool
13+LOG=$SPOOL/pousseur.log
14+REPOS_FILE=~/.ka-pousseur-repos
15+mkdir -p $SPOOL
16+
17+while true; do
18+ if [[ -f $REPOS_FILE ]]; then
19+ while IFS= read -r dir; do
20+ [[ -z $dir || $dir == \#* ]] && continue
21+ d=${dir/#\~/$HOME}
22+ [[ -d $d/.git ]] || continue
23+ ahead=$(/usr/bin/git -C "$d" rev-list --count origin/main..main 2>/dev/null)
24+ if [[ -n $ahead && $ahead -gt 0 ]]; then
25+ if /usr/bin/git -C "$d" push origin main >>$LOG 2>&1; then
26+ echo "$(date '+%F %T') poussé $ahead commit(s): $d" >>$LOG
27+ else
28+ echo "$(date '+%F %T') ÉCHEC push ($ahead en attente): $d" >>$LOG
29+ fi
30+ fi
31+ done <$REPOS_FILE
32+ fi
33+ sleep 300
34+done
added bin/mld +4 −0
@@ -0,0 +1,4 @@
1+#!/bin/bash
2+# mld — MacLustr Dispatch (à exécuter sur la passerelle M1M32)
3+export PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
4+cd "$(dirname "$0")/.." && exec /usr/bin/python3 -m mld "$@"
added mld/__init__.py +2 −0
@@ -0,0 +1,2 @@
1+"""maclustr-dispatch — orchestrateur de déploiement du cluster MacLustr (passerelle M1M32)."""
2+__version__ = "1.0.0"
added mld/__main__.py +3 −0
@@ -0,0 +1,3 @@
1+from .cli import main
2+
3+main()
added mld/cli.py +208 −0
@@ -0,0 +1,208 @@
1+"""CLI `mld` — MacLustr Dispatch (tourne sur la passerelle M1M32)."""
2+import argparse
3+import json
4+import sys
5+from . import config, deploy, manifest, nodes, picker, registry
6+
7+
8+def cmd_discover(a):
9+ nodes.discover()
10+
11+
12+def cmd_scan(a):
13+ nodes.scan(aliases=a.nodes or None)
14+
15+
16+def cmd_nodes(a):
17+ res = nodes.load_scan() or nodes.scan(verbose=False)
18+ nodes.print_scan(res)
19+
20+
21+def cmd_apps(a):
22+ ms = manifest.load_all()
23+ r = registry.load()["apps"]
24+ print("%-18s %-8s %-6s %-30s %-8s %s" % ("app", "nœud", "port", "domaine", "épingle", "runtimes"))
25+ for name, m in ms.items():
26+ cur = r.get(name, {}).get("node") or "—"
27+ print("%-18s %-8s %-6s %-30s %-8s %s" % (name, cur, m.get("port") or "", m.get("domain") or "", m["placement"].get("pin") or "", ",".join(m["requires"]["runtimes"])))
28+
29+
30+def cmd_show(a):
31+ print(json.dumps(manifest.load(a.app), indent=2, ensure_ascii=False))
32+
33+
34+def cmd_pick(a):
35+ m = manifest.load(a.app) if a.app else {"app": "(ad hoc)", "requires": {"runtimes": a.runtime or ["pm2"], "ram_gb": a.ram or 1, "ports": []}, "ram_mb_observed": int((a.ram or 1) * 1024), "size_mb": 0, "placement": {"pin": None, "prefer": None, "avoid": [], "reason": ""}}
36+ scan = nodes.load_scan() if not a.rescan else nodes.scan(verbose=False)
37+ rows = picker.rank(m, scan, exclude=a.exclude or ())
38+ if a.json:
39+ print(json.dumps(rows, indent=2))
40+ return
41+ print("classement pour %s :" % m["app"])
42+ for r in rows:
43+ if r["ok"]:
44+ print(" %-8s score %.3f" % (r["node"], r["score"]))
45+ elif scan.get(r["node"], {}).get("online") and scan[r["node"]].get("role") == "worker":
46+ print(" %-8s — %s" % (r["node"], r["why"]))
47+
48+
49+def cmd_plan(a):
50+ ms = manifest.load_all()
51+ if a.apps:
52+ ms = {k: v for k, v in ms.items() if k in a.apps}
53+ scan = nodes.scan(verbose=False) if a.rescan else (nodes.load_scan() or nodes.scan(verbose=False))
54+ rows = picker.plan(ms, scan, exclude=a.exclude or ())
55+ if a.json:
56+ print(json.dumps(rows, indent=2, ensure_ascii=False))
57+ return
58+ print("%-19s %-8s %-8s %-6s %7s %s" % ("app", "actuel", "proposé", "score", "taille", "note"))
59+ counts = {}
60+ moving_mb = 0
61+ for r in rows:
62+ counts[r["node"]] = counts.get(r["node"], 0) + 1
63+ mv = r["node"] and r["current"] and r["node"] != r["current"]
64+ if mv:
65+ moving_mb += r["size_mb"]
66+ note = ("épinglé — " + r["reason"]) if r["pin"] else (("alt: " + ", ".join("%s %.2f" % x for x in r["alternatives"])) if r["alternatives"] else "")
67+ if not r["node"]:
68+ note = "AUCUN NŒUD : " + " ; ".join("%s (%s)" % b for b in r["blockers"])
69+ flag = "→" if mv else ("=" if r["node"] else "!")
70+ print("%-19s %-8s %-8s %-6s %6dM %s %s" % (r["app"], r["current"] or "—", r["node"] or "—", ("%.3f" % r["score"]) if r["score"] is not None else "", r["size_mb"], flag, note))
71+ print("\nrépartition : " + ", ".join("%s×%s" % (k, v) for k, v in sorted(counts.items(), key=lambda x: -x[1]) if k))
72+ print("données à déplacer : %.1f Go (→ = migration, = = redéploiement en place)" % (moving_mb / 1024.0))
73+
74+
75+def cmd_fetch(a):
76+ deploy.fetch(a.app, a.from_node, final=a.final)
77+
78+
79+def cmd_deploy(a):
80+ if a.node:
81+ node = a.node
82+ else:
83+ scan = nodes.load_scan() or nodes.scan(verbose=False)
84+ rows = picker.rank(manifest.load(a.app), scan)
85+ if not rows or not rows[0]["ok"]:
86+ raise SystemExit("aucun nœud éligible : " + "; ".join("%s: %s" % (r["node"], r["why"]) for r in rows[:6]))
87+ node = rows[0]["node"]
88+ print("nœud choisi : %s (score %.3f)" % (node, rows[0]["score"]))
89+ try:
90+ deploy.deploy(a.app, node, reinstall=a.reinstall, skip_sync=a.skip_sync)
91+ except deploy.DeployError as e:
92+ raise SystemExit("ÉCHEC : %s" % e)
93+
94+
95+def cmd_move(a):
96+ to = a.to
97+ if not to:
98+ scan = nodes.load_scan() or nodes.scan(verbose=False)
99+ rows = picker.rank(manifest.load(a.app), scan, exclude=[registry.node_of(a.app)] if a.away else ())
100+ if not rows or not rows[0]["ok"]:
101+ raise SystemExit("aucun nœud éligible : " + "; ".join("%s: %s" % (r["node"], r["why"]) for r in rows[:6]))
102+ to = rows[0]["node"]
103+ print("nœud choisi : %s (score %.3f)" % (to, rows[0]["score"]))
104+ try:
105+ deploy.move(a.app, to, from_alias=a.from_node, keep_source=a.keep_source, reinstall=a.reinstall)
106+ except deploy.DeployError as e:
107+ raise SystemExit("ÉCHEC : %s" % e)
108+
109+
110+def cmd_stop(a):
111+ deploy.stop(a.app, a.node or registry.node_of(a.app))
112+ registry.set_app(a.app, status="stopped")
113+
114+
115+def cmd_start(a):
116+ node = a.node or registry.node_of(a.app)
117+ deploy.start(a.app, node)
118+ ok, det = deploy.health(a.app, node)
119+ registry.set_app(a.app, node=node, status="online" if ok else "unhealthy", health=det)
120+ print("santé :", det)
121+
122+
123+def cmd_restart(a):
124+ cmd_stop(a)
125+ cmd_start(a)
126+
127+
128+def cmd_retire(a):
129+ deploy.retire(a.app, a.node or registry.node_of(a.app), remove_dir=not a.keep_dir)
130+ if not a.keep_registry:
131+ registry.remove_app(a.app, note=a.note or "")
132+
133+
134+def cmd_status(a):
135+ if a.live:
136+ for row in deploy.live_status(a.app):
137+ print("%-18s %-8s %-30s %-3s %s" % row)
138+ else:
139+ print("%-18s %-8s %-6s %-30s %-10s %s" % ("app", "nœud", "port", "domaine", "état", "déployée"))
140+ for row in deploy.status(a.app):
141+ print("%-18s %-8s %-6s %-30s %-10s %s" % tuple("" if x is None else x for x in row))
142+
143+
144+def cmd_health(a):
145+ ok, det = deploy.health(a.app, a.node or registry.node_of(a.app))
146+ print("OK" if ok else "KO", det)
147+ sys.exit(0 if ok else 1)
148+
149+
150+def cmd_registry(a):
151+ print(json.dumps(registry.load(), indent=2, ensure_ascii=False))
152+
153+
154+def cmd_import(a):
155+ from . import importer
156+ for p in importer.import_node(a.node, apps_filter=a.apps or None):
157+ print("brouillon :", p)
158+
159+
160+def cmd_prepare(a):
161+ from . import prepare
162+ prepare.prepare(a.node, runtimes=a.runtime or None, ka_helpers=a.ka_helpers)
163+
164+
165+def cmd_bootstrap(a):
166+ from . import prepare
167+ prepare.bootstrap(a.node)
168+
169+
170+def cmd_logs(a):
171+ node = a.node or registry.node_of(a.app)
172+ from . import ssh
173+ ip = nodes.ip_of(node)
174+ names = manifest.pm2_names(manifest.load(a.app))
175+ rc, out, err = ssh.run(ip, "pm2 logs %s --nostream --lines %d 2>/dev/null" % (" ".join(names), a.lines), timeout=60)
176+ print(out)
177+
178+
179+def main(argv=None):
180+ p = argparse.ArgumentParser(prog="mld", description="MacLustr Dispatch — déploie les apps depuis la passerelle %s vers le nœud le plus efficient." % config.GATEWAY)
181+ sp = p.add_subparsers(dest="cmd", required=True)
182+ s = sp.add_parser("discover", help="balaye le LAN, met en cache les IP des nœuds"); s.set_defaults(f=cmd_discover)
183+ s = sp.add_parser("scan", help="sonde ressources + runtimes de chaque nœud"); s.add_argument("nodes", nargs="*"); s.set_defaults(f=cmd_scan)
184+ s = sp.add_parser("nodes", help="dernier scan"); s.set_defaults(f=cmd_nodes)
185+ s = sp.add_parser("apps", help="manifestes + emplacement courant"); s.set_defaults(f=cmd_apps)
186+ s = sp.add_parser("show", help="affiche un manifeste"); s.add_argument("app"); s.set_defaults(f=cmd_show)
187+ s = sp.add_parser("pick", help="classe les nœuds pour une app (ou un besoin ad hoc)"); s.add_argument("app", nargs="?"); s.add_argument("--ram", type=float); s.add_argument("--runtime", action="append"); s.add_argument("--exclude", action="append"); s.add_argument("--rescan", action="store_true"); s.add_argument("--json", action="store_true"); s.set_defaults(f=cmd_pick)
188+ s = sp.add_parser("plan", help="plan de placement pour toutes les apps (ou une liste)"); s.add_argument("apps", nargs="*"); s.add_argument("--exclude", action="append"); s.add_argument("--rescan", action="store_true"); s.add_argument("--json", action="store_true"); s.set_defaults(f=cmd_plan)
189+ s = sp.add_parser("fetch", help="copie l'app d'un nœud vers le staging"); s.add_argument("app"); s.add_argument("--from", dest="from_node", required=True); s.add_argument("--final", action="store_true"); s.set_defaults(f=cmd_fetch)
190+ s = sp.add_parser("deploy", help="staging -> nœud (choisi automatiquement sans --node)"); s.add_argument("app"); s.add_argument("--node"); s.add_argument("--reinstall", action="store_true"); s.add_argument("--skip-sync", action="store_true"); s.set_defaults(f=cmd_deploy)
191+ s = sp.add_parser("move", help="migre une app (fetch, stop, deploy, retire)"); s.add_argument("app"); s.add_argument("--to"); s.add_argument("--from", dest="from_node"); s.add_argument("--away", action="store_true", help="exclut le nœud actuel"); s.add_argument("--keep-source", action="store_true"); s.add_argument("--reinstall", action="store_true"); s.set_defaults(f=cmd_move)
192+ s = sp.add_parser("stop"); s.add_argument("app"); s.add_argument("--node"); s.set_defaults(f=cmd_stop)
193+ s = sp.add_parser("start"); s.add_argument("app"); s.add_argument("--node"); s.set_defaults(f=cmd_start)
194+ s = sp.add_parser("restart"); s.add_argument("app"); s.add_argument("--node"); s.set_defaults(f=cmd_restart)
195+ s = sp.add_parser("retire", help="arrête + efface la copie d'un nœud"); s.add_argument("app"); s.add_argument("--node"); s.add_argument("--keep-dir", action="store_true"); s.add_argument("--keep-registry", action="store_true"); s.add_argument("--note"); s.set_defaults(f=cmd_retire)
196+ 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)
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)
199+ s = sp.add_parser("registry", help="registre JSON complet"); s.set_defaults(f=cmd_registry)
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)
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)
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)
203+ a = p.parse_args(argv)
204+ a.f(a)
205+
206+
207+if __name__ == "__main__":
208+ main()
added mld/config.py +76 −0
@@ -0,0 +1,76 @@
1+"""maclustr-dispatch — configuration centrale.
2+
3+Tout tourne SUR la passerelle (M1M32). Python 3.9 (système macOS) : pas de syntaxe > 3.9.
4+"""
5+import os
6+
7+USER = "simon-pierreboucher"
8+HOME = os.path.expanduser("~")
9+GATEWAY = "M1M32"
10+DOMAIN = "maclustr.io"
11+LAN_PREFIX = "192.168.2."
12+SSH_KEY = os.path.join(HOME, ".ssh", "id_ed25519") # clé « maclustr-m1m32 », autorisée sur tous les nœuds
13+STATE = os.path.join(HOME, "dispatch") # état : apps/, stage/, registry.json, lan.json, logs/
14+APPS_DIR = os.path.join(STATE, "apps")
15+STAGE_DIR = os.path.join(STATE, "stage")
16+LOG_DIR = os.path.join(STATE, "logs")
17+REGISTRY = os.path.join(STATE, "registry.json")
18+LAN_CACHE = os.path.join(STATE, "lan.json")
19+SCAN_CACHE = os.path.join(STATE, "scan.json")
20+NGROK_CFG = os.path.join(HOME, "Library", "Application Support", "ngrok", "ngrok.yml")
21+RUN_DIR = "~/dispatch-run" # sur les nœuds : ecosystem PM2 générés
22+SUDO_PW_FILE = os.path.join(STATE, ".sudo") # mot de passe sudo des nœuds (0600), pour `prepare`
23+
24+# Inventaire statique (alias -> cœurs, RAM Go, modèle). Les IP LAN sont découvertes en live.
25+NODES = {
26+ "M3U96a": {"cores": 32, "ram_gb": 96, "model": "Mac Studio"},
27+ "M3U96b": {"cores": 32, "ram_gb": 96, "model": "Mac Studio"},
28+ "M2U64": {"cores": 24, "ram_gb": 64, "model": "Mac Studio"},
29+ "M4M64a": {"cores": 16, "ram_gb": 64, "model": "Mac Studio"},
30+ "M4M64b": {"cores": 16, "ram_gb": 64, "model": "Mac Studio"},
31+ "M4BP48": {"cores": 16, "ram_gb": 48, "model": "MacBook Pro"},
32+ "M4BP36": {"cores": 14, "ram_gb": 36, "model": "MacBook Pro"},
33+ "M4M36": {"cores": 14, "ram_gb": 36, "model": "Mac Studio"},
34+ "M2M32": {"cores": 12, "ram_gb": 32, "model": "Mac Studio"},
35+ "M2M32b": {"cores": 12, "ram_gb": 32, "model": "Mac Studio"},
36+ "M2M32c": {"cores": 12, "ram_gb": 32, "model": "Mac Studio"},
37+ "m4mc": {"cores": 12, "ram_gb": 24, "model": "Mac mini"},
38+ "M1M32": {"cores": 10, "ram_gb": 32, "model": "Mac Studio"},
39+ "m4ma": {"cores": 10, "ram_gb": 24, "model": "Mac mini"},
40+ "m4mb": {"cores": 10, "ram_gb": 16, "model": "Mac mini"},
41+ "m2m16": {"cores": 10, "ram_gb": 16, "model": "Mac mini"},
42+ "M3BA24": {"cores": 8, "ram_gb": 24, "model": "MacBook Air"},
43+ "M3BA16": {"cores": 8, "ram_gb": 16, "model": "MacBook Air"},
44+ "m2m8a": {"cores": 8, "ram_gb": 8, "model": "Mac mini"},
45+ "m2m8b": {"cores": 8, "ram_gb": 8, "model": "Mac mini"},
46+ "m4md": {"cores": 10, "ram_gb": 16, "model": "Mac mini"}, # ajouté 2026-09-04 (Mac16,10, M4, 100.78.227.79)
47+}
48+
49+# Rôles : gateway = jamais d'app ; worker = éligible. Un nœud absent du scan est « offline ».
50+ROLES = {alias: ("gateway" if alias == GATEWAY else "worker") for alias in NODES}
51+
52+# Runtimes que `prepare` sait installer via Homebrew, et comment les détecter sur un nœud.
53+RUNTIME_CHECKS = {
54+ "node": "test -x /opt/homebrew/bin/node",
55+ "pnpm": "test -x /opt/homebrew/bin/pnpm",
56+ "pm2": "test -x /opt/homebrew/bin/pm2",
57+ "ngrok": "(test -x /opt/homebrew/bin/ngrok || test -x $HOME/bin/ngrok) && test -f \"$HOME/Library/Application Support/ngrok/ngrok.yml\"",
58+ "python@3.14": "test -x /opt/homebrew/opt/python@3.14/bin/python3.14",
59+ "python@3.13": "test -x /opt/homebrew/opt/python@3.13/bin/python3.13",
60+ "python@3.12": "test -x /opt/homebrew/opt/python@3.12/bin/python3.12",
61+ "uv": "test -x /opt/homebrew/bin/uv || test -x $HOME/.local/bin/uv",
62+ "uv-python@3.12": "ls -d $HOME/.local/share/uv/python/cpython-3.12* >/dev/null 2>&1",
63+ "docker": "test -x /opt/homebrew/bin/docker",
64+ "postgresql@17": "test -x /opt/homebrew/opt/postgresql@17/bin/postgres",
65+ "brew": "test -x /opt/homebrew/bin/brew",
66+ "git": "test -x /opt/homebrew/bin/git || test -x /usr/bin/git",
67+}
68+BREW_FORMULAE = {
69+ "node": "node", "pnpm": "pnpm", "pm2": "pm2", "python@3.14": "python@3.14", "python@3.13": "python@3.13",
70+ "python@3.12": "python@3.12", "uv": "uv", "docker": "docker", "postgresql@17": "postgresql@17", "git": "git",
71+}
72+BREW_CASKS = {"ngrok": "ngrok"}
73+
74+os.makedirs(APPS_DIR, exist_ok=True)
75+os.makedirs(STAGE_DIR, exist_ok=True)
76+os.makedirs(LOG_DIR, exist_ok=True)
added mld/deploy.py +334 −0
@@ -0,0 +1,334 @@
1+"""Cycle de vie d'une app : fetch (nœud -> staging), deploy (staging -> nœud), stop, start, retire, move, health."""
2+import json
3+import os
4+import shlex
5+import time
6+from . import config, manifest, nodes, registry, render, ssh
7+
8+SYNC_BASE_EXCLUDES = [".DS_Store", "*.log", "__pycache__/", ".pytest_cache/", ".mypy_cache/"]
9+
10+
11+class DeployError(Exception):
12+ pass
13+
14+
15+def _ctx(alias, ip):
16+ lan = nodes.load_lan()["ips"]
17+ return {"HOME": "/Users/%s" % config.USER, "NODE": alias, "LAN_IP": ip, "ips": lan, "USER": config.USER}
18+
19+
20+def _abs(p):
21+ return p.replace("~", "/Users/%s" % config.USER, 1) if p.startswith("~") else p
22+
23+
24+def _log(app, msg):
25+ line = "%s %s" % (time.strftime("%H:%M:%S"), msg)
26+ print(line, flush=True)
27+ with open(os.path.join(config.LOG_DIR, app + ".log"), "a") as f:
28+ f.write(time.strftime("%Y-%m-%d ") + line + "\n")
29+
30+
31+def _ip(alias):
32+ ip = nodes.ip_of(alias)
33+ if not ip:
34+ raise DeployError("nœud %s injoignable sur le LAN" % alias)
35+ return ip
36+
37+
38+def _ensure_dir(ip, d):
39+ """Crée le répertoire cible ; sous /opt (racine root) passe par sudo -S avec le mot de passe de ~/dispatch/.sudo."""
40+ if ssh.ok(ip, "mkdir -p %s 2>/dev/null && test -w %s" % (shlex.quote(d), shlex.quote(d))):
41+ return
42+ pw = open(config.SUDO_PW_FILE).read().strip() if os.path.exists(config.SUDO_PW_FILE) else None
43+ if not pw:
44+ raise DeployError("impossible de créer %s (sudo requis, %s absent)" % (d, config.SUDO_PW_FILE))
45+ rc, out, err = ssh.run(ip, "echo %s | sudo -S -p '' mkdir -p %s && echo %s | sudo -S -p '' chown %s %s" % (shlex.quote(pw), shlex.quote(d), shlex.quote(pw), config.USER, shlex.quote(d)))
46+ if rc != 0:
47+ raise DeployError("création de %s impossible : %s" % (d, err.strip()[-200:]))
48+
49+
50+def stage_dir(app):
51+ return os.path.join(config.STAGE_DIR, app)
52+
53+
54+# ---------------------------------------------------------------- fetch : nœud -> staging
55+def fetch(app, from_alias, final=False):
56+ m = manifest.load(app)
57+ ip = _ip(from_alias)
58+ _log(app, "fetch %s depuis %s (%s)%s" % (m["dir"], from_alias, ip, " [final]" if final else ""))
59+ excl = SYNC_BASE_EXCLUDES + list(m.get("sync_excludes") or [])
60+ rc, out, err = ssh.rsync_pull(ip, _abs(m["dir"]), os.path.join(stage_dir(app), "dir"), excludes=excl)
61+ if rc not in (0, 24):
62+ raise DeployError("rsync fetch a échoué (%s): %s" % (rc, err.strip()[-300:]))
63+ for extra in m.get("extra_paths") or []:
64+ dest = os.path.join(stage_dir(app), "extra", extra.strip("~/").replace("/", "__"))
65+ rc2, out2, err2 = ssh.run(ip, "test -e %s" % shlex.quote(_abs(extra)))
66+ if rc2 != 0:
67+ _log(app, " extra absent sur la source, ignoré : %s" % extra)
68+ continue
69+ isdir = ssh.ok(ip, "test -d %s" % shlex.quote(_abs(extra)))
70+ if isdir:
71+ rc2, out2, err2 = ssh.rsync_pull(ip, _abs(extra), dest, delete=True)
72+ else:
73+ os.makedirs(dest, exist_ok=True)
74+ import subprocess
75+ subprocess.run(["scp", "-p"] + ssh.SSH_OPTS + ["%s:%s" % (ssh.target(ip), _abs(extra)), os.path.join(dest, os.path.basename(extra))], capture_output=True)
76+ json.dump({"app": app, "from": from_alias, "ip": ip, "ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "final": final},
77+ open(os.path.join(stage_dir(app), "fetch.json"), "w"), indent=2)
78+ stat = [l for l in out.splitlines() if "Total transferred file size" in l or "Number of regular files transferred" in l or "Number of files transferred" in l or "Total bytes sent" in l]
79+ _log(app, " ok — " + " ; ".join(s.strip() for s in stat))
80+ return True
81+
82+
83+# ---------------------------------------------------------------- stop / start / status sur un nœud
84+def stop(app, alias, keep_dir=True):
85+ m = manifest.load(app)
86+ ip = _ip(alias)
87+ _log(app, "stop sur %s" % alias)
88+ names = manifest.pm2_names(m)
89+ script = ""
90+ if names:
91+ script += "for n in %s; do pm2 delete $n >/dev/null 2>&1 && echo \" pm2 delete $n\"; done; pm2 save --force >/dev/null 2>&1\n" % " ".join(names)
92+ for lbl in manifest.launchd_labels(m):
93+ script += "launchctl bootout gui/$(id -u)/%s >/dev/null 2>&1 && echo ' bootout %s'; rm -f ~/Library/LaunchAgents/%s.plist\n" % (lbl, lbl, lbl)
94+ if m.get("ngrok"):
95+ script += "pkill -f -- 'url=%s' 2>/dev/null; pkill -f -- 'url=https://%s' 2>/dev/null; pkill -f -- 'domain=%s' 2>/dev/null; true\n" % (m["ngrok"]["url"], m["ngrok"]["url"], m["ngrok"]["url"])
96+ for hook in (m.get("hooks") or {}).get("pre_stop") or []:
97+ script += hook + "\n"
98+ # tue ce qui écoute encore sur les ports de l'app (orphelins)
99+ for p in m["requires"].get("ports") or []:
100+ script += "sleep 1; for pid in $(lsof -nP -iTCP:%s -sTCP:LISTEN -t 2>/dev/null); do kill $pid 2>/dev/null && echo ' kill port %s pid '$pid; done\n" % (p, p)
101+ if m.get("ngrok"):
102+ script += "sleep 4\n" # laisse ngrok libérer le domaine réservé côté service
103+ rc, out, err = ssh.run(ip, script, timeout=180)
104+ for l in out.splitlines():
105+ _log(app, l)
106+ registry.log(app, "stopped", node=alias)
107+ return rc == 0
108+
109+
110+def _install_hooks(m, ip, ctx):
111+ """Commandes post-sync : réécritures .env, hooks, inscription pousseur."""
112+ script = ""
113+ d = _abs(m["dir"])
114+ for fname, kv in (m.get("env_overrides") or {}).items():
115+ target = os.path.join(d, fname)
116+ kv = manifest.render(kv, ctx)
117+ py = "import re,sys,os\np=%r\nkv=%r\ns=open(p).read() if os.path.exists(p) else ''\nfor k,v in kv.items():\n line=k+'='+v\n if re.search(r'^'+re.escape(k)+r'=.*$', s, re.M): s=re.sub(r'^'+re.escape(k)+r'=.*$', line.replace('\\\\','\\\\\\\\'), s, flags=re.M)\n else: s=s.rstrip('\\n')+'\\n'+line+'\\n'\nopen(p,'w').write(s)\nprint(' .env réécrit: '+', '.join(kv))" % (target, kv)
118+ script += "python3 - <<'PYEOF'\n%s\nPYEOF\n" % py
119+ if m.get("ka_repo"):
120+ script += "grep -qx %s ~/.ka-pousseur-repos 2>/dev/null || echo %s >> ~/.ka-pousseur-repos; echo ' pousseur: %s inscrit'\n" % (shlex.quote(m["dir"]), shlex.quote(m["dir"]), m["dir"])
121+ for hook in (m.get("hooks") or {}).get("post_sync") or []:
122+ script += "cd %s && (%s)\n" % (shlex.quote(d), manifest.render(hook, ctx))
123+ return script
124+
125+
126+def deploy(app, alias, reinstall=False, skip_sync=False):
127+ """staging -> nœud, puis démarrage (PM2 + launchd) et healthcheck."""
128+ m = manifest.load(app)
129+ ip = _ip(alias)
130+ ctx = _ctx(alias, ip)
131+ sd = os.path.join(stage_dir(app), "dir")
132+ if not skip_sync and not os.path.isdir(sd):
133+ raise DeployError("staging vide pour %s — faire `mld fetch %s --from <nœud>` ou `mld stage`" % (app, app))
134+ _log(app, "deploy → %s (%s)" % (alias, ip))
135+ # 1. runtimes
136+ scan = nodes.load_scan().get(alias) or nodes.probe(alias, ip)
137+ missing = [r for r in m["requires"]["runtimes"] if not scan.get("runtimes", {}).get(r)]
138+ if missing:
139+ raise DeployError("runtimes manquants sur %s : %s (→ `mld prepare %s`)" % (alias, ", ".join(missing), alias))
140+ # 2. sync
141+ d = _abs(m["dir"])
142+ if not skip_sync:
143+ _ensure_dir(ip, d)
144+ excl = SYNC_BASE_EXCLUDES + list(m.get("sync_excludes") or [])
145+ rc, out, err = ssh.rsync_push(sd, ip, d, excludes=excl, delete=True)
146+ if rc not in (0, 24):
147+ raise DeployError("rsync deploy a échoué (%s): %s" % (rc, err.strip()[-300:]))
148+ stat = [l.strip() for l in out.splitlines() if "Total transferred file size" in l or "Total bytes sent" in l]
149+ _log(app, " sync ok — %s" % (stat[0] if stat else ""))
150+ for extra in m.get("extra_paths") or []:
151+ src = os.path.join(stage_dir(app), "extra", extra.strip("~/").replace("/", "__"))
152+ if not os.path.exists(src):
153+ continue
154+ if os.path.isdir(src) and not os.path.exists(os.path.join(src, os.path.basename(extra))):
155+ ssh.rsync_push(src, ip, _abs(extra), delete=False)
156+ else:
157+ import subprocess
158+ ssh.run(ip, "mkdir -p %s" % shlex.quote(os.path.dirname(_abs(extra))))
159+ subprocess.run(["scp", "-p"] + ssh.SSH_OPTS + [os.path.join(src, os.path.basename(extra)), "%s:%s" % (ssh.target(ip), _abs(extra))], capture_output=True)
160+ _log(app, " extra: %s" % extra)
161+ # 3. install (optionnel) + hooks
162+ script = "cd %s || exit 3\n" % shlex.quote(d)
163+ if reinstall:
164+ script += "if [ -f pnpm-lock.yaml ]; then pnpm install --frozen-lockfile; elif [ -f package-lock.json ]; then npm ci; elif [ -f package.json ]; then npm install; fi\n"
165+ script += "if [ -f requirements.txt ] && [ -x .venv/bin/pip ]; then .venv/bin/pip install -q -r requirements.txt; fi\n"
166+ elif os.path.exists(os.path.join(sd, "package.json")) and os.path.isdir(os.path.join(sd, "node_modules")):
167+ script += "npm rebuild >/dev/null 2>&1 && echo ' npm rebuild ok' || echo ' npm rebuild: avertissement'\n"
168+ script += _install_hooks(m, ip, ctx)
169+ rc, out, err = ssh.run(ip, script, timeout=1800)
170+ for l in out.splitlines()[-15:]:
171+ _log(app, l)
172+ if rc != 0:
173+ raise DeployError("post-sync a échoué (%s): %s" % (rc, (err or out).strip()[-400:]))
174+ # 4. lancement
175+ start(app, alias, m=m, ctx=ctx, ip=ip)
176+ # 5. santé
177+ healthy, detail = health(app, alias, ip=ip, m=m)
178+ registry.set_app(app, node=alias, ip=ip, port=m.get("port"), domain=m.get("domain"), dir=m["dir"], label=m.get("label") or app,
179+ processes=manifest.pm2_names(m), launchd=manifest.launchd_labels(m), deployed=time.strftime("%Y-%m-%d %H:%M"),
180+ status="online" if healthy else "unhealthy", health=detail)
181+ registry.log(app, "deployed", node=alias, healthy=healthy)
182+ _log(app, " santé : %s" % detail)
183+ if not healthy:
184+ names = manifest.pm2_names(m)
185+ rc, out, _ = ssh.run(ip, "pm2 logs %s --nostream --lines 6 2>/dev/null | grep -v '^\\[TAILING' | tail -12" % " ".join(names), timeout=40)
186+ for l in out.splitlines():
187+ _log(app, " log: " + l[:160])
188+ raise DeployError("healthcheck KO sur %s : %s" % (alias, detail))
189+ return True
190+
191+
192+def start(app, alias, m=None, ctx=None, ip=None):
193+ m = m or manifest.load(app)
194+ ip = ip or _ip(alias)
195+ ctx = ctx or _ctx(alias, ip)
196+ _log(app, "start sur %s" % alias)
197+ run_dir = _abs(config.RUN_DIR)
198+ eco = render.ecosystem(m, ctx)
199+ eco_path = "%s/%s.config.cjs" % (run_dir, app)
200+ ssh.write_remote_file(ip, eco_path, eco, mode="600")
201+ script = "mkdir -p %s\n" % run_dir
202+ names = manifest.pm2_names(m)
203+ if names:
204+ script += "for n in %s; do pm2 delete $n >/dev/null 2>&1; done\n" % " ".join(names)
205+ script += "pm2 start %s --update-env 2>&1 | grep -E 'error|Error|\\[PM2\\]' | head -5; pm2 save --force >/dev/null 2>&1; pm2 jlist | python3 -c \"import json,sys; L=json.load(sys.stdin); print(' pm2: '+', '.join(p['name']+'='+p['pm2_env']['status'] for p in L if p['name'] in %s))\" 2>/dev/null || pm2 ls | grep -E '%s'\n" % (shlex.quote(eco_path), json.dumps(names), "|".join(names))
206+ for item in m.get("launchd") or []:
207+ pl = render.plist(item, ctx)
208+ ppath = "%s/Library/LaunchAgents/%s.plist" % (ctx["HOME"], item["label"])
209+ ssh.write_remote_file(ip, ppath, pl, mode="644")
210+ script += "launchctl bootout gui/$(id -u)/%s >/dev/null 2>&1; launchctl bootstrap gui/$(id -u) %s && echo ' launchd: %s chargé' || echo ' launchd: %s ÉCHEC'\n" % (item["label"], shlex.quote(ppath), item["label"], item["label"])
211+ for hook in (m.get("hooks") or {}).get("post_start") or []:
212+ script += "cd %s && (%s)\n" % (shlex.quote(_abs(m["dir"])), manifest.render(hook, ctx))
213+ rc, out, err = ssh.run(ip, script, timeout=300)
214+ for l in out.splitlines():
215+ _log(app, l)
216+ if err.strip():
217+ _log(app, " stderr: " + err.strip()[-200:])
218+ return rc == 0
219+
220+
221+def health(app, alias, ip=None, m=None, timeout=90):
222+ m = m or manifest.load(app)
223+ ip = ip or _ip(alias)
224+ if not m.get("port"):
225+ # pas de port HTTP : vérifie que les processus PM2 sont online
226+ names = manifest.pm2_names(m)
227+ if not names:
228+ return True, "aucun port/processus à vérifier"
229+ rc, out, _ = ssh.run(ip, "pm2 jlist | python3 -c \"import json,sys; L=json.load(sys.stdin); print(sum(1 for p in L if p['name'] in %s and p['pm2_env']['status']=='online'))\"" % json.dumps(names))
230+ n = int((out.strip() or "0").splitlines()[-1] or 0)
231+ return n == len(names), "%d/%d processus PM2 online" % (n, len(names))
232+ url = "http://127.0.0.1:%s%s" % (m["port"], m.get("health_path") or "/")
233+ deadline = time.time() + timeout
234+ code = "000"
235+ while time.time() < deadline:
236+ rc, out, _ = ssh.run(ip, "curl -s -o /dev/null -m 8 -w '%%{http_code}' %s" % shlex.quote(url), timeout=20)
237+ code = out.strip()[-3:] if out.strip() else "000"
238+ if code.isdigit() and code not in ("000",) and int(code) < 500:
239+ break
240+ time.sleep(4)
241+ local_ok = code.isdigit() and code != "000" and int(code) < 500
242+ pub = ""
243+ if m.get("domain") and local_ok and m.get("ngrok"):
244+ import subprocess
245+ for _ in range(20):
246+ p = subprocess.run(["curl", "-s", "-o", "/dev/null", "-m", "12", "-w", "%{http_code}", "https://%s%s" % (m["domain"], m.get("health_path") or "/")], capture_output=True, text=True)
247+ pub = p.stdout.strip()
248+ if pub.isdigit() and int(pub) < 500 and pub != "404":
249+ break
250+ time.sleep(5)
251+ detail = "local %s" % code + (" / public https://%s %s" % (m["domain"], pub) if pub else "")
252+ ok = local_ok and (not pub or (pub.isdigit() and int(pub) < 500 and pub != "404"))
253+ return ok, detail
254+
255+
256+def retire(app, alias, remove_dir=True):
257+ """Arrête et efface la copie d'un nœud (après migration réussie)."""
258+ m = manifest.load(app)
259+ ip = _ip(alias)
260+ stop(app, alias)
261+ if remove_dir:
262+ d = _abs(m["dir"])
263+ rc, out, err = ssh.run(ip, "rm -rf %s && echo ' rm -rf %s'; rm -f %s/%s.config.cjs; grep -v -x %s ~/.ka-pousseur-repos > ~/.ka-pousseur-repos.tmp 2>/dev/null && mv ~/.ka-pousseur-repos.tmp ~/.ka-pousseur-repos; true" % (shlex.quote(d), d, _abs(config.RUN_DIR), app, shlex.quote(m["dir"])), timeout=600)
264+ for l in out.splitlines():
265+ _log(app, l)
266+ registry.log(app, "retired", node=alias)
267+ return True
268+
269+
270+def move(app, to_alias, from_alias=None, keep_source=False, reinstall=False):
271+ """Migration complète : pré-sync à chaud, arrêt, sync final, déploiement, santé, retrait de la source (ou rollback)."""
272+ from_alias = from_alias or registry.node_of(app) or manifest.load(app).get("source_node")
273+ if not from_alias:
274+ raise DeployError("nœud source inconnu pour %s (préciser --from)" % app)
275+ if from_alias == to_alias:
276+ _log(app, "déjà sur %s → redéploiement en place" % to_alias)
277+ fetch(app, from_alias)
278+ stop(app, from_alias)
279+ fetch(app, from_alias, final=True)
280+ return deploy(app, to_alias, reinstall=reinstall)
281+ _log(app, "MOVE %s : %s → %s" % (app, from_alias, to_alias))
282+ fetch(app, from_alias) # 1. copie à chaud (gros volume)
283+ stop(app, from_alias) # 2. arrêt source
284+ fetch(app, from_alias, final=True) # 3. delta final (cohérence des bases)
285+ try:
286+ deploy(app, to_alias, reinstall=reinstall) # 4. cible
287+ except DeployError as e:
288+ _log(app, "ÉCHEC sur %s : %s — rollback sur %s" % (to_alias, e, from_alias))
289+ try:
290+ stop(app, to_alias)
291+ except Exception:
292+ pass
293+ start(app, from_alias)
294+ ok, det = health(app, from_alias)
295+ registry.set_app(app, node=from_alias, status="online" if ok else "unhealthy", health=det)
296+ raise
297+ if not keep_source:
298+ retire(app, from_alias) # 5. nettoyage source
299+ registry.log(app, "moved", **{"from": from_alias, "to": to_alias})
300+ _log(app, "MOVE terminé : %s tourne sur %s" % (app, to_alias))
301+ return True
302+
303+
304+def status(app=None):
305+ r = registry.load()
306+ apps = [app] if app else sorted(r["apps"])
307+ rows = []
308+ for a in apps:
309+ v = r["apps"].get(a)
310+ if not v:
311+ rows.append((a, "—", "", "", "non déployée", ""))
312+ continue
313+ rows.append((a, v.get("node"), v.get("port"), v.get("domain") or "", v.get("status"), v.get("deployed")))
314+ return rows
315+
316+
317+def live_status(app=None, public=True):
318+ """Vérifie en live (PM2 + port + domaine) chaque app du registre."""
319+ r = registry.load()
320+ apps = [app] if app else sorted(r["apps"])
321+ out = []
322+ for a in apps:
323+ v = r["apps"].get(a)
324+ if not v:
325+ continue
326+ m = manifest.load(a)
327+ try:
328+ ip = _ip(v["node"])
329+ ok, det = health(a, v["node"], ip=ip, m=m, timeout=10) if public else (None, "")
330+ except DeployError as e:
331+ ok, det = False, str(e)
332+ registry.set_app(a, status="online" if ok else "unhealthy", health=det)
333+ out.append((a, v["node"], v.get("domain") or "", "OK" if ok else "KO", det))
334+ return out
added mld/importer.py +81 −0
@@ -0,0 +1,81 @@
1+"""`mld import <nœud>` : brouillons de manifestes à partir des processus PM2 / launchd vivants d'un nœud.
2+
3+Regroupe par préfixe de nom (lou-ka-web, lou-ka-sync, lou-ka-ngrok → lou-ka). Les brouillons vont dans
4+~/dispatch/apps/_drafts/<app>.json ; on les relit, on ajuste, puis on les déplace dans ~/dispatch/apps/.
5+"""
6+import json
7+import os
8+import re
9+from . import config, manifest, nodes, ssh
10+
11+SUFFIXES = ("-web", "-sync", "-ngrok", "-api", "-tunnel", "-server", "-etl", "-scheduler", "-ingest", "-market",
12+ "-worker", "-proxy", "-crawler", "-enrichment", "-sitemaps", "-tun-search", "-tun-embed")
13+ENV_DROP_PREFIX = ("PM2_", "npm_", "__", "TERM", "SSH_", "LC_", "HOMEBREW", "ITERM", "XPC_", "COLOR", "LS_", "SHELL", "PWD", "OLDPWD",
14+ "PATH", "HOME", "USER", "LOGNAME", "LANG", "TMPDIR", "SECURITYSESSIONID", "COMMAND_MODE", "unique_id", "FPATH",
15+ "CLAUDE", "OTEL", "DISPLAY", "MallocNanoZone", "ZSH", "EDITOR", "PAGER", "LESS", "NoDefaultCurrentDirectoryInExePath",
16+ "COREPACK", "AI_AGENT", "OSLogRateLimit", "GIT_", "ANTHROPIC_MODEL", "DISABLE_", "MANPATH", "INFOPATH", "STARSHIP",
17+ "TERMINFO", "WINDOWID", "NVM", "P9K", "TMUX", "SSH", "_")
18+
19+
20+def app_of(name):
21+ for s in SUFFIXES:
22+ if name.endswith(s):
23+ return name[: -len(s)]
24+ return name
25+
26+
27+def clean_env(env, name):
28+ out = {}
29+ for k, v in (env or {}).items():
30+ if k == name or k.startswith(ENV_DROP_PREFIX) or not isinstance(v, str):
31+ continue
32+ out[k] = v
33+ return out
34+
35+
36+def import_node(alias, apps_filter=None):
37+ ip = nodes.ip_of(alias)
38+ rc, out, err = ssh.run(ip, "pm2 jlist 2>/dev/null; echo; echo __MEM__; pm2 jlist 2>/dev/null | python3 -c \"import json,sys; print(json.dumps({p['name']: p.get('monit',{}).get('memory',0) for p in json.load(sys.stdin)}))\"", timeout=60)
39+ raw, _, mem = out.partition("__MEM__")
40+ L = json.loads(raw.strip() or "[]")
41+ mems = json.loads(mem.strip() or "{}")
42+ groups = {}
43+ for p in L:
44+ e = p.get("pm2_env", {})
45+ app = app_of(p["name"])
46+ if apps_filter and app not in apps_filter:
47+ continue
48+ g = groups.setdefault(app, {"app": app, "processes": [], "ngrok": None, "cwd": None, "ram": 0})
49+ g["ram"] += int(mems.get(p["name"], 0)) // (1024 * 1024)
50+ args = e.get("args") or []
51+ script = e.get("pm_exec_path") or ""
52+ if "ngrok" in script or (args and "ngrok" in " ".join(map(str, args))):
53+ blob = " ".join([script] + [str(a) for a in args])
54+ mo = re.search(r"--(?:url|domain)=(?:https?://)?([^\s]+)", blob)
55+ port = re.search(r"\s(\d{2,5})(?:\s|$)", blob)
56+ g["ngrok"] = {"name": p["name"], "url": mo.group(1) if mo else None, "port": int(port.group(1)) if port else None}
57+ continue
58+ g["processes"].append({
59+ "name": p["name"], "manager": "pm2", "script": script, "args": args,
60+ "interpreter": e.get("exec_interpreter") if e.get("exec_interpreter") not in (None, "none", "N/A") else None,
61+ "cwd": e.get("pm_cwd"), "env": clean_env(e.get("env"), p["name"]), "cron_restart": e.get("cron_restart"),
62+ "autorestart": e.get("autorestart", True), "max_memory_restart": e.get("max_memory_restart"),
63+ })
64+ g["cwd"] = g["cwd"] or e.get("pm_cwd")
65+ drafts = os.path.join(config.APPS_DIR, "_drafts")
66+ os.makedirs(drafts, exist_ok=True)
67+ written = []
68+ for app, g in groups.items():
69+ cwd = g["cwd"] or ""
70+ m = json.loads(json.dumps(manifest.DEFAULTS))
71+ mo = re.match(r"(/Users/[^/]+/(?:apps/)?[^/]+|/opt/[^/]+)", cwd)
72+ d = mo.group(1) if mo else cwd
73+ m.update({"app": app, "label": app, "dir": d.replace("/Users/%s" % config.USER, "~"), "processes": g["processes"], "ngrok": g["ngrok"],
74+ "domain": (g["ngrok"] or {}).get("url"), "port": (g["ngrok"] or {}).get("port"), "ram_mb_observed": max(g["ram"], 128)})
75+ m["requires"]["ports"] = [m["port"]] if m["port"] else []
76+ m["placement"]["reason"] = "importé de %s" % alias
77+ manifest.detect_requirements(m)
78+ p = os.path.join(drafts, app + ".json")
79+ json.dump(m, open(p, "w"), indent=2, ensure_ascii=False)
80+ written.append(p)
81+ return written
added mld/manifest.py +112 −0
@@ -0,0 +1,112 @@
1+"""Manifestes d'apps : ~/dispatch/apps/<app>.json (source de vérité de « comment » tourne une app).
2+
3+Schéma :
4+{
5+ "app": "lou-ka", "label": "Lou-Ka", "domain": "www.lou-ka.com", "port": 8095, "health_path": "/",
6+ "dir": "~/apps/lou-ka", # répertoire de l'app (même chemin absolu sur tous les nœuds)
7+ "extra_paths": ["~/.ssh/trouveka_tunnel"], # autres fichiers/dossiers à copier avec l'app
8+ "sync_excludes": ["data/backups/"], # exclusions rsync (relatives à dir)
9+ "requires": {"runtimes": ["python@3.14", "pm2", "ngrok"], "ram_gb": 2, "ports": [8095]},
10+ "ram_mb_observed": 400, "size_mb": 4359,
11+ "placement": {"pin": null, "prefer": null, "avoid": [], "reason": ""},
12+ "processes": [{"name": "lou-ka-web", "manager": "pm2", "script": "...", "args": [...], "interpreter": null,
13+ "cwd": "...", "env": {...}, "cron_restart": null, "autorestart": true, "max_memory_restart": null}],
14+ "ngrok": {"name": "lou-ka-ngrok", "url": "www.lou-ka.com", "port": 8095} | null,
15+ "launchd": [{"label": "...", "program_arguments": [...], "working_directory": "...", "env": {...}, "keep_alive": true}],
16+ "env_overrides": {".env": {"DATABASE_URL": "postgresql://...@{{IP:M2M32}}:5432/x"}}, # réécritures de fichiers .env
17+ "hooks": {"post_sync": ["bash ..."], "post_start": []},
18+ "ka_repo": true # inscrire dir dans ~/.ka-pousseur-repos du nœud hôte
19+}
20+Gabarits acceptés dans env/args/env_overrides : {{HOME}}, {{NODE}}, {{LAN_IP}}, {{IP:<alias>}}.
21+"""
22+import glob
23+import json
24+import os
25+import re
26+from . import config
27+
28+DEFAULTS = {
29+ "label": None, "domain": None, "port": None, "health_path": "/", "extra_paths": [], "sync_excludes": [],
30+ "requires": {"runtimes": ["pm2"], "ram_gb": 1, "ports": []}, "ram_mb_observed": 512, "size_mb": 0,
31+ "placement": {"pin": None, "prefer": None, "avoid": [], "reason": ""}, "processes": [], "ngrok": None,
32+ "launchd": [], "env_overrides": {}, "hooks": {"post_sync": [], "post_start": []}, "ka_repo": False,
33+}
34+
35+
36+def path(app):
37+ return os.path.join(config.APPS_DIR, app + ".json")
38+
39+
40+def load(app):
41+ p = path(app)
42+ if not os.path.exists(p):
43+ raise SystemExit("manifeste introuvable : %s" % p)
44+ m = json.load(open(p))
45+ for k, v in DEFAULTS.items():
46+ m.setdefault(k, json.loads(json.dumps(v)))
47+ m["requires"].setdefault("runtimes", ["pm2"])
48+ m["requires"].setdefault("ram_gb", 1)
49+ m["requires"].setdefault("ports", [m["port"]] if m.get("port") else [])
50+ return m
51+
52+
53+def save(m):
54+ json.dump(m, open(path(m["app"]), "w"), indent=2, ensure_ascii=False)
55+ os.chmod(path(m["app"]), 0o600)
56+
57+
58+def all_apps():
59+ return sorted(os.path.basename(p)[:-5] for p in glob.glob(os.path.join(config.APPS_DIR, "*.json")))
60+
61+
62+def load_all():
63+ return {a: load(a) for a in all_apps()}
64+
65+
66+def render(value, ctx):
67+ """Remplace {{HOME}}, {{NODE}}, {{LAN_IP}}, {{IP:alias}} dans une chaîne / liste / dict."""
68+ if isinstance(value, str):
69+ def sub(mo):
70+ key = mo.group(1)
71+ if key.startswith("IP:"):
72+ return ctx.get("ips", {}).get(key[3:], mo.group(0))
73+ return str(ctx.get(key, mo.group(0)))
74+ return re.sub(r"\{\{([A-Za-z0-9_:@.-]+)\}\}", sub, value)
75+ if isinstance(value, list):
76+ return [render(v, ctx) for v in value]
77+ if isinstance(value, dict):
78+ return {k: render(v, ctx) for k, v in value.items()}
79+ return value
80+
81+
82+def pm2_names(m):
83+ names = [p["name"] for p in m["processes"] if p.get("manager", "pm2") == "pm2"]
84+ if m.get("ngrok"):
85+ names.append(m["ngrok"]["name"])
86+ return names
87+
88+
89+def launchd_labels(m):
90+ return [l["label"] for l in m.get("launchd", [])]
91+
92+
93+def detect_requirements(m):
94+ """Déduit les runtimes requis à partir des processus (complète requires.runtimes)."""
95+ req = set(m["requires"].get("runtimes") or [])
96+ req.add("pm2")
97+ if m.get("ngrok"):
98+ req.add("ngrok")
99+ blob = json.dumps(m["processes"]) + json.dumps(m.get("launchd", []))
100+ if "/opt/homebrew/bin/node" in blob or "next" in blob or "npm" in blob or ".mjs" in blob or ".js" in blob:
101+ req.add("node")
102+ if "pnpm" in blob:
103+ req.add("pnpm")
104+ vp = m.get("venv_python") or ""
105+ for ver in ("3.14", "3.13", "3.12"):
106+ if "python@%s" % ver in vp or "/%s/" % ver in vp or "python%s" % ver in vp:
107+ req.add("python@%s" % ver)
108+ if "uv/python/cpython-3.12" in vp:
109+ req.discard("python@3.12")
110+ req.add("uv-python@3.12")
111+ m["requires"]["runtimes"] = sorted(req)
112+ return m
added mld/nodes.py +155 −0
@@ -0,0 +1,155 @@
1+"""Découverte LAN des nœuds + sonde de ressources/runtimes (scan)."""
2+import json
3+import os
4+import time
5+from concurrent.futures import ThreadPoolExecutor
6+from . import config, ssh
7+
8+RUNTIME_PROBE = "\n".join('if %s; then echo "RT %s 1"; else echo "RT %s 0"; fi' % (cmd, name, name) for name, cmd in config.RUNTIME_CHECKS.items())
9+
10+
11+def _probe_script():
12+ # sonde 100 % shell (les minis sans Xcode CLT n'ont pas de python3 fonctionnel) : lignes "KV clé valeur"
13+ sh = r'''
14+export LC_ALL=C
15+echo "KV host $(hostname -s)"
16+echo "KV marker $(cat ~/.maclustr-node 2>/dev/null)"
17+echo "KV ip $(ifconfig | awk '/inet 192\.168\.2\./{print $2; exit}')"
18+echo "KV os $(sw_vers -productVersion)"
19+echo "KV cores $(sysctl -n hw.ncpu)"
20+echo "KV ram_gb $(( $(sysctl -n hw.memsize) / 1073741824 ))"
21+echo "KV load1 $(sysctl -n vm.loadavg | tr -d '{}' | awk '{print $1}' | tr ',' '.')"
22+echo "KV free_gb $(vm_stat | awk '/Pages (free|inactive|speculative|purgeable)/{gsub("\\.","",$NF); s+=$NF} END{printf "%.1f", s*16384/1073741824}')"
23+echo "KV disk_free_gb $(df -g / | awk 'NR==2{print $4}')"
24+echo "KV boot $(sysctl -n kern.boottime | sed -E 's/.*sec = ([0-9]+).*/\1/')"
25+echo "KV ports $(lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | awk 'NR>1{n=split($9,a,":"); print a[n]}' | sort -un | tr '\n' ',')"
26+echo "KV pm2 $(pm2 jlist 2>/dev/null | tr -d '\n' | grep -o '"name":"[^"]*"' | cut -d'"' -f4 | sort -u | tr '\n' ',')"
27+'''
28+ return sh + "\n" + RUNTIME_PROBE + "\n"
29+
30+
31+def load_lan():
32+ if os.path.exists(config.LAN_CACHE):
33+ return json.load(open(config.LAN_CACHE))
34+ return {"updated": None, "ips": {}}
35+
36+
37+def save_lan(d):
38+ d["updated"] = time.strftime("%Y-%m-%dT%H:%M:%S")
39+ json.dump(d, open(config.LAN_CACHE, "w"), indent=2)
40+
41+
42+def _identify(ip):
43+ rc, out, _ = ssh.run(ip, "cat ~/.maclustr-node 2>/dev/null || hostname -s", timeout=12, path=False)
44+ if rc == 0 and out.strip():
45+ return ip, out.strip().splitlines()[0].strip()
46+ return ip, None
47+
48+
49+def discover(rng=range(1, 255), verbose=True):
50+ """Balaye 192.168.2.0/24 en SSH (clé passerelle) et lit le marqueur ~/.maclustr-node."""
51+ ips = [config.LAN_PREFIX + str(i) for i in rng]
52+ found = {}
53+ with ThreadPoolExecutor(max_workers=48) as ex:
54+ for ip, alias in ex.map(_identify, ips):
55+ if alias:
56+ # normalise l'alias (le marqueur est la source de vérité, sinon hostname)
57+ for a in config.NODES:
58+ if a.lower() == alias.lower():
59+ alias = a
60+ break
61+ found[alias] = ip
62+ lan = load_lan()
63+ lan["ips"] = found
64+ save_lan(lan)
65+ if verbose:
66+ for a in sorted(found, key=lambda x: list(config.NODES).index(x) if x in config.NODES else 99):
67+ print(" %-8s %s" % (a, found[a]))
68+ missing = [a for a in config.NODES if a not in found]
69+ if missing:
70+ print(" hors LAN / éteints : %s" % ", ".join(missing))
71+ return found
72+
73+
74+def ip_of(alias, rediscover=True):
75+ lan = load_lan()
76+ ip = lan["ips"].get(alias)
77+ if ip and ssh.ok(ip, "true", timeout=15):
78+ return ip
79+ if rediscover:
80+ found = discover(verbose=False)
81+ return found.get(alias)
82+ return ip
83+
84+
85+def probe(alias, ip):
86+ rc, out, err = ssh.run(ip, _probe_script(), timeout=90)
87+ info = {"alias": alias, "ip": ip, "online": False}
88+ if "KV cores" not in out:
89+ info["error"] = (err or out).strip()[:200]
90+ return info
91+ rts = {}
92+ kv = {}
93+ for line in out.splitlines():
94+ if line.startswith("KV "):
95+ parts = line.split(" ", 2)
96+ kv[parts[1]] = parts[2].strip() if len(parts) > 2 else ""
97+ elif line.startswith("RT "):
98+ _, name, val = line.split(" ", 2)
99+ rts[name] = val.strip() == "1"
100+ def num(k, f=float, d=0):
101+ try:
102+ return f(kv.get(k, "") or d)
103+ except Exception:
104+ return d
105+ info.update({"host": kv.get("host", ""), "marker": kv.get("marker", ""), "os": kv.get("os", ""), "cores": num("cores", int),
106+ "ram_gb": num("ram_gb", int), "load1": num("load1"), "free_gb": num("free_gb"), "disk_free_gb": num("disk_free_gb", int)})
107+ if kv.get("ip"):
108+ info["ip"] = kv["ip"]
109+ try:
110+ info["uptime_h"] = round((time.time() - int(kv.get("boot") or 0)) / 3600, 1) if kv.get("boot") else None
111+ except Exception:
112+ info["uptime_h"] = None
113+ info["ports"] = sorted(set(int(p) for p in kv.get("ports", "").split(",") if p.isdigit()))
114+ info["pm2"] = [p for p in kv.get("pm2", "").split(",") if p]
115+ info["runtimes"] = rts
116+ info["online"] = info["cores"] > 0
117+ info.update({k: v for k, v in config.NODES.get(alias, {}).items() if k not in info or not info.get(k)})
118+ info["role"] = config.ROLES.get(alias, "worker")
119+ return info
120+
121+
122+def scan(aliases=None, verbose=True):
123+ lan = load_lan()
124+ if not lan["ips"]:
125+ discover(verbose=False)
126+ lan = load_lan()
127+ targets = [(a, ip) for a, ip in lan["ips"].items() if not aliases or a in aliases]
128+ res = {}
129+ with ThreadPoolExecutor(max_workers=24) as ex:
130+ for info in ex.map(lambda t: probe(*t), targets):
131+ res[info["alias"]] = info
132+ for a in config.NODES:
133+ if a not in res:
134+ res[a] = {"alias": a, "online": False, "role": config.ROLES[a], "runtimes": {}, **config.NODES[a]}
135+ json.dump({"updated": time.strftime("%Y-%m-%dT%H:%M:%S"), "nodes": res}, open(config.SCAN_CACHE, "w"), indent=2)
136+ if verbose:
137+ print_scan(res)
138+ return res
139+
140+
141+def load_scan():
142+ if os.path.exists(config.SCAN_CACHE):
143+ return json.load(open(config.SCAN_CACHE))["nodes"]
144+ return {}
145+
146+
147+def print_scan(res):
148+ print("%-8s %-6s %-16s %5s %6s %6s %6s %7s %4s %s" % ("nœud", "rôle", "ip", "cœurs", "RAM", "libre", "load", "disque", "pm2", "runtimes"))
149+ for a in config.NODES:
150+ n = res.get(a, {})
151+ if not n.get("online"):
152+ print("%-8s %-6s %-16s — hors ligne" % (a, n.get("role", ""), n.get("ip", "")))
153+ continue
154+ rt = ",".join(k.replace("python@", "py").replace("uv-python@", "uvpy") for k, v in sorted(n["runtimes"].items()) if v)
155+ print("%-8s %-6s %-16s %5s %5sG %5sG %6.2f %6sG %4s %s" % (a, n["role"], n["ip"], n["cores"], n["ram_gb"], n["free_gb"], n["load1"], n["disk_free_gb"], len(n.get("pm2", [])), rt))
added mld/picker.py +96 −0
@@ -0,0 +1,96 @@
1+"""Sélection du nœud le plus efficient pour une app (ou un lot d'apps : plan)."""
2+from . import config, registry
3+
4+
5+def eligible(node, m, projected, exclude=()):
6+ """Retourne (ok, raison)."""
7+ a = node["alias"]
8+ pin = m["placement"].get("pin")
9+ if node.get("role") != "worker" and not (pin == a):
10+ return False, "rôle %s" % node.get("role")
11+ if not node.get("online"):
12+ return False, "hors ligne"
13+ if a in exclude or a in (m["placement"].get("avoid") or []):
14+ return False, "exclu"
15+ if pin and pin != a:
16+ return False, "épinglé sur %s" % pin
17+ missing = [r for r in m["requires"]["runtimes"] if not node["runtimes"].get(r)]
18+ if missing:
19+ return False, "runtimes manquants : %s" % ",".join(missing)
20+ need = max(float(m["requires"].get("ram_gb") or 1), m.get("ram_mb_observed", 512) * 1.5 / 1024)
21+ free = node["free_gb"] - projected[a]["ram_gb"]
22+ if free - need < 2.0:
23+ return False, "RAM insuffisante (%.1f G libres, besoin %.1f G)" % (free, need)
24+ if node["disk_free_gb"] < (m.get("size_mb", 0) / 1024) * 1.5 + 20:
25+ return False, "disque insuffisant"
26+ busy = set(node.get("ports", [])) | projected[a]["ports"]
27+ if not _is_home(a, m):
28+ for p in m["requires"].get("ports") or []:
29+ if p in busy:
30+ return False, "port %s occupé" % p
31+ return True, ""
32+
33+
34+def _is_home(alias, m):
35+ """Le nœud héberge déjà cette app (source d'import ou registre) : ses propres ports ne la bloquent pas."""
36+ return alias == m.get("source_node") or alias == registry.node_of(m["app"])
37+
38+
39+STAY_BONUS = 0.05 # évite de déplacer des Go de données pour un gain de score marginal
40+
41+
42+def score(node, m, projected):
43+ a = node["alias"]
44+ need = max(float(m["requires"].get("ram_gb") or 1), m.get("ram_mb_observed", 512) * 1.5 / 1024)
45+ free_after = node["free_gb"] - projected[a]["ram_gb"] - need
46+ ram_ratio = max(0.0, free_after / max(node["ram_gb"], 1))
47+ idle = max(0.0, 1.0 - (node["load1"] + 0.5 * projected[a]["apps"]) / max(node["cores"], 1))
48+ disk = min(1.0, node["disk_free_gb"] / 500.0)
49+ cores = min(1.0, node["cores"] / 32.0)
50+ hosted = len(registry.apps_on(a)) + projected[a]["apps"]
51+ s = 0.45 * ram_ratio + 0.25 * idle + 0.10 * disk + 0.15 * cores - 0.04 * hosted
52+ if m["placement"].get("prefer") == a:
53+ s += 0.15
54+ if _is_home(a, m):
55+ s += STAY_BONUS
56+ return round(s, 4)
57+
58+
59+def rank(m, scan, projected=None, exclude=()):
60+ projected = projected or empty_projection(scan)
61+ rows = []
62+ for a, node in scan.items():
63+ ok, why = eligible(node, m, projected, exclude)
64+ rows.append({"node": a, "ok": ok, "why": why, "score": score(node, m, projected) if ok else -1})
65+ rows.sort(key=lambda r: (-r["ok"], -r["score"]))
66+ return rows
67+
68+
69+def empty_projection(scan):
70+ return {a: {"ram_gb": 0.0, "apps": 0, "ports": set()} for a in scan}
71+
72+
73+def plan(manifests, scan, exclude=()):
74+ """Placement glouton : apps les plus lourdes d'abord, projection des ressources consommées."""
75+ projected = empty_projection(scan)
76+ order = sorted(manifests.values(), key=lambda m: -(m.get("ram_mb_observed", 512) + m.get("size_mb", 0) / 20.0))
77+ out = []
78+ for m in order:
79+ rows = rank(m, scan, projected, exclude)
80+ best = rows[0] if rows and rows[0]["ok"] else None
81+ if best:
82+ a = best["node"]
83+ need = max(float(m["requires"].get("ram_gb") or 1), m.get("ram_mb_observed", 512) * 1.5 / 1024)
84+ projected[a]["ram_gb"] += need
85+ projected[a]["apps"] += 1
86+ projected[a]["ports"] |= set(m["requires"].get("ports") or [])
87+ pin = m["placement"].get("pin")
88+ blockers = []
89+ if not best:
90+ cand = [r for r in rows if not r["ok"] and (r["node"] == pin if pin else (scan[r["node"]].get("online") and scan[r["node"]].get("role") == "worker"))]
91+ cand.sort(key=lambda r: -(scan[r["node"]].get("ram_gb") or 0))
92+ blockers = [(r["node"], r["why"]) for r in cand][:8]
93+ out.append({"app": m["app"], "node": best["node"] if best else None, "score": best["score"] if best else None,
94+ "current": registry.node_of(m["app"]) or m.get("source_node"), "pin": pin, "reason": m["placement"].get("reason", ""),
95+ "size_mb": m.get("size_mb", 0), "alternatives": [(r["node"], r["score"]) for r in rows[1:4] if r["ok"]], "blockers": blockers})
96+ return out
added mld/prepare.py +129 −0
@@ -0,0 +1,129 @@
1+"""`mld prepare <nœud>` : installe les runtimes manquants (Homebrew), copie la config ngrok, active pm2 au démarrage, pose le marqueur."""
2+import os
3+import shlex
4+from . import config, nodes, ssh
5+
6+
7+def _sudo_pw():
8+ if os.path.exists(config.SUDO_PW_FILE):
9+ return open(config.SUDO_PW_FILE).read().strip()
10+ return None
11+
12+
13+def prepare(alias, runtimes=None, ka_helpers=False):
14+ ip = nodes.ip_of(alias)
15+ if not ip:
16+ raise SystemExit("nœud %s injoignable" % alias)
17+ info = nodes.probe(alias, ip)
18+ have = info.get("runtimes", {})
19+ want = runtimes or ["node", "pnpm", "pm2", "ngrok", "python@3.14", "python@3.13", "uv", "git"]
20+ print("prepare %s (%s) — présents : %s" % (alias, ip, ", ".join(k for k, v in have.items() if v)))
21+ if not have.get("brew"):
22+ print(" ✗ Homebrew absent : bootstrap requis (Xcode CLT). Lancer d'abord : mld bootstrap %s" % alias)
23+ return False
24+ missing = [r for r in want if not have.get(r)]
25+ script = ""
26+ for r in missing:
27+ if r in config.BREW_FORMULAE:
28+ script += "echo ' brew install %s'; brew install -q %s >/dev/null 2>&1 || echo ' ! échec %s'\n" % (r, config.BREW_FORMULAE[r], r)
29+ elif r in config.BREW_CASKS:
30+ script += "echo ' brew install --cask ngrok'; brew install -q --cask ngrok >/dev/null 2>&1 || brew install -q ngrok/ngrok/ngrok >/dev/null 2>&1 || echo ' ! échec ngrok'\n"
31+ # config ngrok (authtoken) depuis la passerelle
32+ if "ngrok" in missing or not have.get("ngrok"):
33+ if os.path.exists(config.NGROK_CFG):
34+ ssh.run(ip, "mkdir -p \"$HOME/Library/Application Support/ngrok\"")
35+ ssh.scp_to(ip, config.NGROK_CFG, "'/Users/%s/Library/Application Support/ngrok/ngrok.yml'" % config.USER)
36+ script += "echo ' ngrok.yml copié'\n"
37+ # marqueur
38+ script += "[ -f ~/.maclustr-node ] || echo %s > ~/.maclustr-node\n" % alias
39+ # pm2 startup (launchd) — nécessite sudo
40+ pw = _sudo_pw()
41+ script += "if ! ls ~/Library/LaunchAgents 2>/dev/null | grep -qi pm2; then cmd=$(pm2 startup launchd -u %s --hp /Users/%s 2>/dev/null | grep '^sudo' | head -1); if [ -n \"$cmd\" ]; then %s echo \" pm2 startup: $( ${cmd#sudo } >/dev/null 2>&1 && echo ok || echo 'à faire manuellement')\"; fi; fi\n" % (
42+ config.USER, config.USER, ("echo %s | sudo -S -v 2>/dev/null;" % shlex.quote(pw)) if pw else "")
43+ rc, out, err = ssh.run(ip, script, timeout=1800)
44+ if ka_helpers:
45+ out += ka_helpers_install(alias, ip)
46+ print(out)
47+ if err.strip():
48+ print(" stderr:", err.strip()[-300:])
49+ info = nodes.probe(alias, ip)
50+ still = [r for r in want if not info.get("runtimes", {}).get(r)]
51+ print(" résultat : %s" % ("tout est là" if not still else "manquent encore " + ", ".join(still)))
52+ return not still
53+
54+
55+def bootstrap(alias):
56+ """Installe Xcode CLT + Homebrew sans GUI. Active un sudo NOPASSWD temporaire (retiré à la fin). Long (plusieurs Go)."""
57+ ip = nodes.ip_of(alias)
58+ pw = _sudo_pw()
59+ if not pw:
60+ raise SystemExit("mot de passe sudo requis dans %s" % config.SUDO_PW_FILE)
61+ script = r'''
62+export LC_ALL=C
63+echo %s | sudo -S -p '' sh -c 'echo "%s ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/mld-bootstrap && chmod 440 /etc/sudoers.d/mld-bootstrap'
64+trap 'sudo rm -f /etc/sudoers.d/mld-bootstrap' EXIT
65+if ! xcode-select -p >/dev/null 2>&1; then
66+ echo ' installation Command Line Tools...'
67+ touch /tmp/.com.apple.dt.CommandLineTools.installondemand.in-progress
68+ PROD=$(softwareupdate -l 2>&1 | grep -oE 'Label: Command Line Tools for Xcode[^,]*' | sed 's/^Label: //' | sort -V | tail -1)
69+ echo " produit: ${PROD:-introuvable}"
70+ if [ -n "$PROD" ]; then sudo softwareupdate -i "$PROD" --verbose 2>&1 | tail -3; fi
71+ rm -f /tmp/.com.apple.dt.CommandLineTools.installondemand.in-progress
72+ sudo xcode-select --switch /Library/Developer/CommandLineTools 2>/dev/null || true
73+fi
74+if [ ! -x /opt/homebrew/bin/brew ]; then
75+ echo ' installation Homebrew...'
76+ NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" 2>&1 | tail -4
77+ grep -q 'opt/homebrew/bin/brew shellenv' ~/.zprofile 2>/dev/null || echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile
78+fi
79+echo " CLT: $(xcode-select -p 2>/dev/null || echo absent) brew: $(/opt/homebrew/bin/brew --version 2>/dev/null | head -1 || echo absent)"
80+''' % (shlex.quote(pw), config.USER)
81+ rc, out, err = ssh.run(ip, script, timeout=5400)
82+ print(out)
83+ if err.strip():
84+ print(" stderr:", err.strip()[-400:])
85+ return rc == 0
86+
87+
88+def gateway_mdns():
89+ import subprocess
90+ name = subprocess.run(["scutil", "--get", "LocalHostName"], capture_output=True, text=True).stdout.strip()
91+ return (name + ".local") if name else nodes.load_lan()["ips"].get(config.GATEWAY, "")
92+
93+
94+def ka_helpers_install(alias, ip):
95+ """Pousseur Ka (launchd zsh → git push vers spbgit) + alias SSH `gitsrv` → passerelle + clé autorisée sur la passerelle."""
96+ assets = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets")
97+ out = [" ka-helpers sur %s :" % alias]
98+ ssh.write_remote_file(ip, "/Users/%s/ka-pousseur.sh" % config.USER, open(os.path.join(assets, "ka-pousseur.sh")).read(), mode="755")
99+ ssh.write_remote_file(ip, "/Users/%s/Library/LaunchAgents/com.ka.pousseur.plist" % config.USER, open(os.path.join(assets, "com.ka.pousseur.plist")).read(), mode="644")
100+ mdns = gateway_mdns()
101+ block = "Host gitsrv\n HostName %s\n User %s\n IdentityFile ~/.ssh/ka_guardian_ed25519\n IdentitiesOnly yes\n ControlMaster no\n ControlPath none\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null\n LogLevel ERROR\n\n" % (mdns, config.USER)
102+ script = r"""
103+mkdir -p ~/.ssh ~/ka-guardian-spool; touch ~/.ka-pousseur-repos; chmod 700 ~/.ssh
104+[ -f ~/.ssh/ka_guardian_ed25519 ] || ssh-keygen -q -t ed25519 -N '' -C "ka-guardian@$(cat ~/.maclustr-node 2>/dev/null || hostname -s)" -f ~/.ssh/ka_guardian_ed25519
105+# alias gitsrv en TÊTE du config (avant Host *) — remplace un bloc existant
106+python3 - <<'PYEOF'
107+import os,re
108+p=os.path.expanduser('~/.ssh/config'); s=open(p).read() if os.path.exists(p) else ''
109+s=re.sub(r'(?ms)^Host gitsrv\n(?:[ \t]+.*\n?)*\n?', '', s)
110+open(p,'w').write(%r + s); os.chmod(p, 0o600); print(' gitsrv -> %s')
111+PYEOF
112+cat ~/.ssh/ka_guardian_ed25519.pub
113+launchctl bootout gui/$(id -u)/com.ka.pousseur >/dev/null 2>&1; launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.ka.pousseur.plist && echo ' pousseur launchd chargé'
114+""" % (block, mdns)
115+ rc, o, e = ssh.run(ip, script, timeout=120)
116+ pub = [l for l in o.splitlines() if l.startswith("ssh-ed25519")]
117+ out += [" " + l for l in o.splitlines() if not l.startswith("ssh-ed25519")]
118+ if pub:
119+ ak = os.path.expanduser("~/.ssh/authorized_keys")
120+ cur = open(ak).read() if os.path.exists(ak) else ""
121+ if pub[0].split()[1] not in cur:
122+ with open(ak, "a") as f:
123+ f.write(pub[0].strip() + "\n")
124+ out.append(" clé %s autorisée sur la passerelle" % (pub[0].split()[-1] if len(pub[0].split()) > 2 else alias))
125+ else:
126+ out.append(" clé déjà autorisée sur la passerelle")
127+ rc, o, e = ssh.run(ip, "ssh -o BatchMode=yes -o ConnectTimeout=6 gitsrv 'echo ok' 2>&1 | tail -1", timeout=30)
128+ out.append(" test ssh gitsrv : %s" % (o.strip() or e.strip()[-80:]))
129+ return "\n".join(out) + "\n"
added mld/registry.py +51 −0
@@ -0,0 +1,51 @@
1+"""Registre des déploiements (source de vérité) : ~/dispatch/registry.json."""
2+import json
3+import os
4+import time
5+from . import config
6+
7+
8+def load():
9+ if os.path.exists(config.REGISTRY):
10+ return json.load(open(config.REGISTRY))
11+ return {"updated": None, "gateway": config.GATEWAY, "apps": {}, "history": []}
12+
13+
14+def save(r):
15+ r["updated"] = time.strftime("%Y-%m-%dT%H:%M:%S")
16+ r["gateway"] = config.GATEWAY
17+ tmp = config.REGISTRY + ".tmp"
18+ json.dump(r, open(tmp, "w"), indent=2, ensure_ascii=False)
19+ os.replace(tmp, config.REGISTRY)
20+
21+
22+def node_of(app):
23+ return load()["apps"].get(app, {}).get("node")
24+
25+
26+def apps_on(alias):
27+ return [a for a, v in load()["apps"].items() if v.get("node") == alias]
28+
29+
30+def set_app(app, **fields):
31+ r = load()
32+ cur = r["apps"].get(app, {})
33+ cur.update(fields)
34+ cur["updated"] = time.strftime("%Y-%m-%dT%H:%M:%S")
35+ r["apps"][app] = cur
36+ save(r)
37+
38+
39+def remove_app(app, note=""):
40+ r = load()
41+ if app in r["apps"]:
42+ r["history"].append({"ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "app": app, "event": "removed", "was": r["apps"][app], "note": note})
43+ del r["apps"][app]
44+ save(r)
45+
46+
47+def log(app, event, **kw):
48+ r = load()
49+ r["history"].append(dict(ts=time.strftime("%Y-%m-%dT%H:%M:%S"), app=app, event=event, **kw))
50+ r["history"] = r["history"][-500:]
51+ save(r)
added mld/render.py +89 −0
@@ -0,0 +1,89 @@
1+"""Génération des fichiers de lancement : ecosystem PM2 (.cjs) et plists launchd."""
2+import json
3+import plistlib
4+import shlex
5+
6+
7+def _mem(v):
8+ if not v:
9+ return None
10+ if isinstance(v, str):
11+ return v
12+ return "%dM" % (int(v) // (1024 * 1024))
13+
14+
15+def _home(path, ctx):
16+ """PM2/launchd n'expandent pas « ~ » : chemin absolu obligatoire."""
17+ if isinstance(path, str) and path.startswith("~"):
18+ return ctx["HOME"] + path[1:]
19+ return path
20+
21+
22+def ecosystem(m, ctx):
23+ """Retourne le contenu JS d'un ecosystem.config.cjs pour les processus PM2 + tunnel ngrok."""
24+ from .manifest import render
25+ apps = []
26+ for p in m["processes"]:
27+ if p.get("manager", "pm2") != "pm2":
28+ continue
29+ a = {
30+ "name": p["name"],
31+ "script": _home(render(p["script"], ctx), ctx),
32+ "cwd": _home(render(p.get("cwd") or m["dir"], ctx), ctx),
33+ "exec_mode": "fork",
34+ "instances": 1,
35+ "autorestart": p.get("autorestart", True),
36+ "merge_logs": True,
37+ "time": True,
38+ "env": render(p.get("env") or {}, ctx),
39+ }
40+ if p.get("args"):
41+ args = p["args"]
42+ a["args"] = " ".join(shlex.quote(str(x)) for x in render(args, ctx)) if isinstance(args, list) else render(args, ctx)
43+ interp = p.get("interpreter")
44+ if interp and interp not in ("none", "N/A"):
45+ a["interpreter"] = render(interp, ctx)
46+ elif interp in ("none",) or (p["script"].endswith((".sh", ".mjs", ".js", ".py", ".cjs")) is False):
47+ a["interpreter"] = "none"
48+ if p.get("cron_restart"):
49+ a["cron_restart"] = p["cron_restart"]
50+ if p.get("max_memory_restart"):
51+ a["max_memory_restart"] = _mem(p["max_memory_restart"])
52+ if p.get("restart_delay"):
53+ a["restart_delay"] = p["restart_delay"]
54+ if p.get("node_args"):
55+ a["node_args"] = p["node_args"]
56+ apps.append(a)
57+ if m.get("ngrok"):
58+ n = m["ngrok"]
59+ args = ["http", "--url=%s" % n["url"], str(n["port"]), "--log=stdout"] + list(n.get("extra_args") or [])
60+ apps.append({
61+ "name": n["name"], "script": n.get("binary") or "/opt/homebrew/bin/ngrok", "args": " ".join(args),
62+ "interpreter": "none", "cwd": _home(render(m["dir"], ctx), ctx), "exec_mode": "fork", "autorestart": True,
63+ "restart_delay": 5000, "merge_logs": True, "time": True,
64+ })
65+ head = "// généré par maclustr-dispatch — ne pas éditer à la main (app: %s, nœud: %s)\n" % (m["app"], ctx.get("NODE"))
66+ return head + "module.exports = { apps: %s };\n" % json.dumps(apps, indent=2, ensure_ascii=False)
67+
68+
69+def plist(item, ctx):
70+ from .manifest import render
71+ d = {
72+ "Label": item["label"],
73+ "ProgramArguments": render(item["program_arguments"], ctx),
74+ "RunAtLoad": item.get("run_at_load", True),
75+ "KeepAlive": item.get("keep_alive", True),
76+ "StandardOutPath": render(item.get("stdout") or "{{HOME}}/Library/Logs/%s.log" % item["label"], ctx),
77+ "StandardErrorPath": render(item.get("stderr") or "{{HOME}}/Library/Logs/%s.err.log" % item["label"], ctx),
78+ }
79+ if item.get("working_directory"):
80+ d["WorkingDirectory"] = _home(render(item["working_directory"], ctx), ctx)
81+ env = dict(item.get("env") or {})
82+ env.setdefault("HOME", ctx["HOME"])
83+ env.setdefault("PATH", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin")
84+ d["EnvironmentVariables"] = render(env, ctx)
85+ if item.get("start_interval"):
86+ d["StartInterval"] = int(item["start_interval"])
87+ if item.get("throttle_interval"):
88+ d["ThrottleInterval"] = int(item["throttle_interval"])
89+ return plistlib.dumps(d).decode()
added mld/ssh.py +75 −0
@@ -0,0 +1,75 @@
1+"""SSH / rsync vers les nœuds via le LAN 192.168.2.x (le SSH inter-nœuds Tailscale est bloqué par ACL)."""
2+import os
3+import shlex
4+import subprocess
5+from . import config
6+
7+SSH_OPTS = [
8+ "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "StrictHostKeyChecking=no",
9+ "-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR", "-o", "ServerAliveInterval=15",
10+ "-i", config.SSH_KEY,
11+]
12+# PATH complet sur le nœud (les shells non interactifs n'ont pas Homebrew)
13+REMOTE_PATH = "export PATH=/opt/homebrew/bin:/opt/homebrew/sbin:$HOME/bin:$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin; "
14+
15+
16+def target(ip):
17+ return "%s@%s" % (config.USER, ip)
18+
19+
20+def run(ip, cmd, timeout=120, stdin=None, path=True):
21+ """Exécute `cmd` (bash) sur le nœud. Retourne (rc, stdout, stderr)."""
22+ full = (REMOTE_PATH if path else "") + cmd
23+ try:
24+ p = subprocess.run(["ssh"] + SSH_OPTS + [target(ip), "bash -s"], input=full if stdin is None else stdin,
25+ capture_output=True, text=True, timeout=timeout)
26+ return p.returncode, p.stdout, p.stderr
27+ except subprocess.TimeoutExpired:
28+ return 124, "", "timeout after %ss" % timeout
29+
30+
31+def run_script(ip, script, timeout=300):
32+ """Envoie un script complet sur stdin de `bash -s`."""
33+ return run(ip, "", timeout=timeout, stdin=REMOTE_PATH + "\n" + script)
34+
35+
36+def ok(ip, cmd, timeout=60):
37+ return run(ip, cmd, timeout)[0] == 0
38+
39+
40+def rsync_push(local_dir, ip, remote_dir, excludes=(), delete=True, timeout=7200, extra=()):
41+ """Passerelle -> nœud. remote_dir peut contenir ~ (résolu côté nœud)."""
42+ args = ["rsync", "-a", "--stats", "-e", "ssh " + " ".join(shlex.quote(o) for o in SSH_OPTS)]
43+ if delete:
44+ args.append("--delete")
45+ for e in excludes:
46+ args += ["--exclude", e]
47+ args += list(extra)
48+ args += ["--rsync-path", "mkdir -p %s && rsync" % shlex.quote(remote_dir), local_dir.rstrip("/") + "/", "%s:%s/" % (target(ip), remote_dir)]
49+ p = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
50+ return p.returncode, p.stdout, p.stderr
51+
52+
53+def rsync_pull(ip, remote_dir, local_dir, excludes=(), delete=True, timeout=7200):
54+ """Nœud -> passerelle (staging)."""
55+ os.makedirs(local_dir, exist_ok=True)
56+ args = ["rsync", "-a", "--stats", "-e", "ssh " + " ".join(shlex.quote(o) for o in SSH_OPTS)]
57+ if delete:
58+ args.append("--delete")
59+ for e in excludes:
60+ args += ["--exclude", e]
61+ args += ["%s:%s/" % (target(ip), remote_dir), local_dir.rstrip("/") + "/"]
62+ p = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
63+ return p.returncode, p.stdout, p.stderr
64+
65+
66+def scp_to(ip, local_file, remote_path):
67+ p = subprocess.run(["scp"] + SSH_OPTS + [local_file, "%s:%s" % (target(ip), remote_path)], capture_output=True, text=True)
68+ return p.returncode == 0
69+
70+
71+def write_remote_file(ip, remote_path, content, mode="644"):
72+ """Écrit un fichier sur le nœud via heredoc (contenu texte)."""
73+ q = shlex.quote(remote_path)
74+ script = "mkdir -p $(dirname %s) && cat > %s <<'__MLD_EOF__'\n%s\n__MLD_EOF__\nchmod %s %s" % (q, q, content, mode, q)
75+ return run(ip, script, timeout=60)
76
Menu

Theme