"""CLI `mld` — MacLustr Dispatch (tourne sur la passerelle M1M32).""" import argparse import json import sys from . import config, deploy, manifest, nodes, picker, registry def cmd_discover(a): nodes.discover() def cmd_scan(a): nodes.scan(aliases=a.nodes or None) def cmd_nodes(a): res = nodes.load_scan() or nodes.scan(verbose=False) nodes.print_scan(res) def cmd_apps(a): ms = manifest.load_all() r = registry.load()["apps"] print("%-18s %-8s %-6s %-30s %-8s %s" % ("app", "nœud", "port", "domaine", "épingle", "runtimes")) for name, m in ms.items(): cur = r.get(name, {}).get("node") or "—" 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"]))) def cmd_show(a): print(json.dumps(manifest.load(a.app), indent=2, ensure_ascii=False)) def cmd_pick(a): 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": ""}} scan = nodes.load_scan() if not a.rescan else nodes.scan(verbose=False) rows = picker.rank(m, scan, exclude=a.exclude or ()) if a.json: print(json.dumps(rows, indent=2)) return print("classement pour %s :" % m["app"]) for r in rows: if r["ok"]: print(" %-8s score %.3f" % (r["node"], r["score"])) elif scan.get(r["node"], {}).get("online") and scan[r["node"]].get("role") == "worker": print(" %-8s — %s" % (r["node"], r["why"])) def cmd_plan(a): ms = manifest.load_all() if a.apps: ms = {k: v for k, v in ms.items() if k in a.apps} scan = nodes.scan(verbose=False) if a.rescan else (nodes.load_scan() or nodes.scan(verbose=False)) rows = picker.plan(ms, scan, exclude=a.exclude or ()) if a.json: print(json.dumps(rows, indent=2, ensure_ascii=False)) return print("%-19s %-8s %-8s %-6s %7s %s" % ("app", "actuel", "proposé", "score", "taille", "note")) counts = {} moving_mb = 0 for r in rows: counts[r["node"]] = counts.get(r["node"], 0) + 1 mv = r["node"] and r["current"] and r["node"] != r["current"] if mv: moving_mb += r["size_mb"] note = ("épinglé — " + r["reason"]) if r["pin"] else (("alt: " + ", ".join("%s %.2f" % x for x in r["alternatives"])) if r["alternatives"] else "") if not r["node"]: note = "AUCUN NŒUD : " + " ; ".join("%s (%s)" % b for b in r["blockers"]) flag = "→" if mv else ("=" if r["node"] else "!") 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)) print("\nrépartition : " + ", ".join("%s×%s" % (k, v) for k, v in sorted(counts.items(), key=lambda x: -x[1]) if k)) print("données à déplacer : %.1f Go (→ = migration, = = redéploiement en place)" % (moving_mb / 1024.0)) def cmd_fetch(a): deploy.fetch(a.app, a.from_node, final=a.final) def cmd_deploy(a): if a.node: node = a.node else: scan = nodes.load_scan() or nodes.scan(verbose=False) rows = picker.rank(manifest.load(a.app), scan) if not rows or not rows[0]["ok"]: raise SystemExit("aucun nœud éligible : " + "; ".join("%s: %s" % (r["node"], r["why"]) for r in rows[:6])) node = rows[0]["node"] print("nœud choisi : %s (score %.3f)" % (node, rows[0]["score"])) try: deploy.deploy(a.app, node, reinstall=a.reinstall, skip_sync=a.skip_sync) except deploy.DeployError as e: raise SystemExit("ÉCHEC : %s" % e) def cmd_move(a): to = a.to if not to: scan = nodes.load_scan() or nodes.scan(verbose=False) rows = picker.rank(manifest.load(a.app), scan, exclude=[registry.node_of(a.app)] if a.away else ()) if not rows or not rows[0]["ok"]: raise SystemExit("aucun nœud éligible : " + "; ".join("%s: %s" % (r["node"], r["why"]) for r in rows[:6])) to = rows[0]["node"] print("nœud choisi : %s (score %.3f)" % (to, rows[0]["score"])) try: deploy.move(a.app, to, from_alias=a.from_node, keep_source=a.keep_source, reinstall=a.reinstall) except deploy.DeployError as e: raise SystemExit("ÉCHEC : %s" % e) def cmd_stop(a): deploy.stop(a.app, a.node or registry.node_of(a.app)) registry.set_app(a.app, status="stopped") def cmd_start(a): node = a.node or registry.node_of(a.app) deploy.start(a.app, node) ok, det = deploy.health(a.app, node) registry.set_app(a.app, node=node, status="online" if ok else "unhealthy", health=det) print("santé :", det) def cmd_restart(a): cmd_stop(a) cmd_start(a) def cmd_retire(a): deploy.retire(a.app, a.node or registry.node_of(a.app), remove_dir=not a.keep_dir) if not a.keep_registry: registry.remove_app(a.app, note=a.note or "") # app retirée du cluster : sa route publique tombe (page 404 MacLustr au lieu d'un 502) from . import tunnel try: tunnel.remove_route(manifest.load(a.app)) except tunnel.TunnelError as e: print(" tunnel :", e) def cmd_tunnel(a): from . import tunnel try: if a.sub == "status": tunnel.print_status(a.gateway) elif a.sub == "peer": for n in a.args: tunnel.ensure_peer(n, a.gateway, log=print) or tunnel.peer(n, a.gateway, log=print) elif a.sub == "route": for app in a.args: node = registry.node_of(app) if not node: print("%s : pas dans le registre" % app) continue m = manifest.load(app) s = tunnel.ensure_route(m, node, log=print) if s: registry.set_app(app, tunnel={"gateway": s["gateway"], "domain": s["domain"], "upstream": s["upstreams"][0], "url": "https://%s" % s["domain"]}) else: print("%s : pas de domaine → rien à exposer" % app) elif a.sub == "rm": for app in a.args: tunnel.remove_route(manifest.load(app)) except tunnel.TunnelError as e: raise SystemExit("ÉCHEC : %s" % e) def cmd_heal(a): from . import heal if a.install: heal.install(interval=a.interval) return s = heal.heal(apps_filter=a.apps or None, nodes_filter=a.nodes or None, dry_run=a.dry_run, quiet=a.quiet, public=not a.no_public) sys.exit(0 if not s["ko"] else 1) def cmd_harden(a): from . import harden autologin = True if a.autologin else (False if a.no_autologin else None) if a.all or not a.nodes: harden.harden_all(aliases=a.nodes or None, autologin=autologin) else: for n in a.nodes: harden.harden(n, autologin=autologin) def cmd_status(a): if a.live: for row in deploy.live_status(a.app): print("%-18s %-8s %-30s %-3s %s" % row) else: print("%-18s %-8s %-6s %-30s %-10s %s" % ("app", "nœud", "port", "domaine", "état", "déployée")) for row in deploy.status(a.app): print("%-18s %-8s %-6s %-30s %-10s %s" % tuple("" if x is None else x for x in row)) def cmd_health(a): ok, det = deploy.health(a.app, a.node or registry.node_of(a.app)) print("OK" if ok else "KO", det) sys.exit(0 if ok else 1) def cmd_registry(a): if a.push: res = registry.push_to_subscribers() bad = [x for x in res if not x[1]] sys.exit(1 if bad else 0) print(json.dumps(registry.load(), indent=2, ensure_ascii=False)) def cmd_subscribers(a): subs = registry.load_subscribers() if a.add: kind, target, path = a.add if kind not in ("app", "node"): raise SystemExit("usage : mld subscribers --add app|node ") subs = [s for s in subs if not (s.get(kind) == target and s.get("path") == path)] subs.append({kind: target, "path": path}) registry.save_subscribers(subs) if a.remove: kind, target = a.remove subs = [s for s in subs if s.get(kind) != target] registry.save_subscribers(subs) r = registry.load() print("%-22s %-8s %-15s %s" % ("abonné", "nœud", "ip", "chemin")) for s in subs: try: label, node, ip, path = registry.resolve_subscriber(s, r) print("%-22s %-8s %-15s %s" % (label, node, ip, path)) except ValueError as e: print("%-22s %-8s %-15s %s" % (s.get("app") or s.get("node"), "—", "—", "(%s)" % e)) def cmd_import(a): from . import importer for p in importer.import_node(a.node, apps_filter=a.apps or None): print("brouillon :", p) def cmd_prepare(a): from . import prepare prepare.prepare(a.node, runtimes=a.runtime or None, ka_helpers=a.ka_helpers, tunnel_peer=not a.no_tunnel, harden=not a.no_harden) def cmd_bootstrap(a): from . import prepare prepare.bootstrap(a.node) def cmd_logs(a): node = a.node or registry.node_of(a.app) from . import ssh ip = nodes.ip_of(node) names = manifest.pm2_names(manifest.load(a.app)) rc, out, err = ssh.run(ip, "pm2 logs %s --nostream --lines %d 2>/dev/null" % (" ".join(names), a.lines), timeout=60) print(out) def main(argv=None): 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) sp = p.add_subparsers(dest="cmd", required=True) s = sp.add_parser("discover", help="balaye le LAN, met en cache les IP des nœuds"); s.set_defaults(f=cmd_discover) s = sp.add_parser("scan", help="sonde ressources + runtimes de chaque nœud"); s.add_argument("nodes", nargs="*"); s.set_defaults(f=cmd_scan) s = sp.add_parser("nodes", help="dernier scan"); s.set_defaults(f=cmd_nodes) s = sp.add_parser("apps", help="manifestes + emplacement courant"); s.set_defaults(f=cmd_apps) s = sp.add_parser("show", help="affiche un manifeste"); s.add_argument("app"); s.set_defaults(f=cmd_show) 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) 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) 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) 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) 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) s = sp.add_parser("stop"); s.add_argument("app"); s.add_argument("--node"); s.set_defaults(f=cmd_stop) s = sp.add_parser("start"); s.add_argument("app"); s.add_argument("--node"); s.set_defaults(f=cmd_start) s = sp.add_parser("restart"); s.add_argument("app"); s.add_argument("--node"); s.set_defaults(f=cmd_restart) 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) s = sp.add_parser("status"); s.add_argument("app", nargs="?"); s.add_argument("--live", action="store_true"); s.set_defaults(f=cmd_status) s = sp.add_parser("health"); s.add_argument("app"); s.add_argument("--node"); s.set_defaults(f=cmd_health) 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) 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) 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) 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) s = sp.add_parser("prepare", help="runtimes Homebrew + pm2 startup + raccordement tunnel + harden sur un nœud"); s.add_argument("node"); s.add_argument("--runtime", action="append"); s.add_argument("--ka-helpers", action="store_true"); s.add_argument("--no-tunnel", action="store_true"); s.add_argument("--no-harden", action="store_true"); s.set_defaults(f=cmd_prepare) 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) s = sp.add_parser("tunnel", help="MacLustr Tunnel : status | peer … | route … | rm …"); s.add_argument("sub", choices=["status", "peer", "route", "rm"]); s.add_argument("args", nargs="*"); s.add_argument("-g", "--gateway", help="BHS64 (défaut) ou R9128"); s.set_defaults(f=cmd_tunnel) s = sp.add_parser("heal", help="auto-réparation : PM2/launchd/wg1/routes de chaque app du registre (--install = LaunchDaemon 5 min)"); s.add_argument("--apps", nargs="*"); s.add_argument("--nodes", nargs="*"); s.add_argument("--dry-run", action="store_true"); s.add_argument("--quiet", action="store_true"); s.add_argument("--no-public", action="store_true"); s.add_argument("--install", action="store_true"); s.add_argument("--interval", type=int, help="secondes entre deux passages (avec --install)"); s.set_defaults(f=cmd_heal) s = sp.add_parser("harden", help="résilience d'un nœud : auto-login, pas de veille, redémarrage après coupure/gel, MAJ macOS + Tailscale off"); s.add_argument("nodes", nargs="*"); s.add_argument("--all", action="store_true"); s.add_argument("--autologin", action="store_true", help="force l'auto-login même sur un nœud loué"); s.add_argument("--no-autologin", action="store_true"); s.set_defaults(f=cmd_harden) a = p.parse_args(argv) a.f(a) if __name__ == "__main__": main()