spb/maclustr-dispatch
Public
1"""Manifestes d'apps : ~/dispatch/apps/<app>.json (source de vérité de « comment » tourne une app).23Sché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'app8 "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 .env17 "hooks": {"post_sync": ["bash ..."], "post_start": []},18 "ka_repo": true # inscrire dir dans ~/.ka-pousseur-repos du nœud hôte19}20Gabarits acceptés dans env/args/env_overrides : {{HOME}}, {{NODE}}, {{LAN_IP}}, {{IP:<alias>}}.21"""22import glob23import json24import os25import re26from . import config2728DEFAULTS = {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}343536def path(app):37 return os.path.join(config.APPS_DIR, app + ".json")383940def 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 m515253def save(m):54 json.dump(m, open(path(m["app"]), "w"), indent=2, ensure_ascii=False)55 os.chmod(path(m["app"]), 0o600)565758def all_apps():59 return sorted(os.path.basename(p)[:-5] for p in glob.glob(os.path.join(config.APPS_DIR, "*.json")))606162def load_all():63 return {a: load(a) for a in all_apps()}646566def 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 value808182def 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 names878889def launchd_labels(m):90 return [l["label"] for l in m.get("launchd", [])]919293def 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 m113