SPB Git forge
29commits 1branches 0releases
684.0 KBsize
maindefault branch
2 days agolast push
Python 95.8% Shell 4.2%
6.4 KB · 144 lines python
Raw Blame History
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"], "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  "tunnel": {"domain": "www.lou-ka.com", "gateway": "BHS64", "redirects": ["lou-ka.com"], "websocket": false} | null,15                                          # exposition publique via MacLustr Tunnel : la route Caddy https://domain →16                                          # <nœud courant>:<port> est (re)pointée par `mld deploy`/`move`. `domain` seul suffit.17  "ngrok": null,                          # OBSOLÈTE (ngrok retiré le 2026-09-10) : un vieux bloc est converti en `tunnel` au chargement18  "launchd": [{"label": "...", "program_arguments": [...], "working_directory": "...", "env": {...}, "keep_alive": true}],19  "env_overrides": {".env": {"DATABASE_URL": "postgresql://...@{{IP:M2M32}}:5432/x"}},  # réécritures de fichiers .env20  "hooks": {"post_sync": ["bash ..."], "post_start": []},21  "ka_repo": true                          # inscrire dir dans ~/.ka-pousseur-repos du nœud hôte22}23Gabarits acceptés dans env/args/env_overrides : {{HOME}}, {{NODE}}, {{LAN_IP}}, {{IP:<alias>}}.24"""25import glob26import json27import os28import re29from . import config3031DEFAULTS = {32    "label": None, "domain": None, "port": None, "health_path": "/", "extra_paths": [], "sync_excludes": [],33    "requires": {"runtimes": ["pm2"], "ram_gb": 1, "ports": []}, "ram_mb_observed": 512, "size_mb": 0,34    "placement": {"pin": None, "prefer": None, "avoid": [], "reason": ""}, "processes": [], "tunnel": None, "ngrok": None,35    "launchd": [], "env_overrides": {}, "hooks": {"post_sync": [], "post_start": []}, "ka_repo": False,36}373839def path(app):40    return os.path.join(config.APPS_DIR, app + ".json")414243def load(app):44    p = path(app)45    if not os.path.exists(p):46        raise SystemExit("manifeste introuvable : %s" % p)47    m = json.load(open(p))48    for k, v in DEFAULTS.items():49        m.setdefault(k, json.loads(json.dumps(v)))50    m["requires"].setdefault("runtimes", ["pm2"])51    m["requires"].setdefault("ram_gb", 1)52    m["requires"].setdefault("ports", [m["port"]] if m.get("port") else [])53    return modernize(m)545556def modernize(m):57    """ngrok → MacLustr Tunnel (2026-09-10). Un ancien bloc `ngrok` devient une route `tunnel` ; le runtime « ngrok » disparaît58    des prérequis (plus installé nulle part) ; le nom du vieux processus PM2 `<app>-ngrok` est gardé pour le nettoyer au start."""59    n = m.get("ngrok")60    if n:61        if not m.get("tunnel"):62            m["tunnel"] = {"domain": n.get("url") or m.get("domain"), "gateway": config.TUNNEL_DEFAULT,63                           "note": "converti automatiquement depuis l'ancien bloc ngrok"}64        if not m.get("domain"):65            m["domain"] = n.get("url")66        if not m.get("port") and n.get("port"):67            m["port"] = n["port"]68        if n.get("name"):69            m["legacy_pm2"] = sorted(set((m.get("legacy_pm2") or []) + [n["name"]]))70        m["ngrok"] = None71    if m.get("domain") and not m.get("tunnel"):72        m["tunnel"] = {"domain": m["domain"], "gateway": config.TUNNEL_DEFAULT}73    if m.get("tunnel") and not m["tunnel"].get("domain"):74        m["tunnel"]["domain"] = m.get("domain")75    if m.get("tunnel") and not m.get("domain"):76        m["domain"] = m["tunnel"]["domain"]77    m["requires"]["runtimes"] = [r for r in m["requires"]["runtimes"] if r != "ngrok"]78    # les vieux processus `<app>-ngrok` (PM2) sont supprimés à chaque start/stop s'ils traînent encore79    m["legacy_pm2"] = sorted(set((m.get("legacy_pm2") or []) + ["%s-ngrok" % m["app"]]))80    return m818283def save(m):84    json.dump(m, open(path(m["app"]), "w"), indent=2, ensure_ascii=False)85    os.chmod(path(m["app"]), 0o600)868788def all_apps():89    return sorted(os.path.basename(p)[:-5] for p in glob.glob(os.path.join(config.APPS_DIR, "*.json")))909192def load_all():93    return {a: load(a) for a in all_apps()}949596def render(value, ctx):97    """Remplace {{HOME}}, {{NODE}}, {{LAN_IP}}, {{IP:alias}} dans une chaîne / liste / dict."""98    if isinstance(value, str):99        def sub(mo):100            key = mo.group(1)101            if key.startswith("IP:"):102                return ctx.get("ips", {}).get(key[3:], mo.group(0))103            return str(ctx.get(key, mo.group(0)))104        return re.sub(r"\{\{([A-Za-z0-9_:@.-]+)\}\}", sub, value)105    if isinstance(value, list):106        return [render(v, ctx) for v in value]107    if isinstance(value, dict):108        return {k: render(v, ctx) for k, v in value.items()}109    return value110111112def pm2_names(m):113    return [p["name"] for p in m["processes"] if p.get("manager", "pm2") == "pm2"]114115116def legacy_pm2_names(m):117    """Processus PM2 d'anciennes versions du manifeste (tunnels ngrok) à supprimer s'ils existent encore sur le nœud."""118    return [n for n in (m.get("legacy_pm2") or []) if n not in pm2_names(m)]119120121def launchd_labels(m):122    return [l["label"] for l in m.get("launchd", [])]123124125def detect_requirements(m):126    """Déduit les runtimes requis à partir des processus (complète requires.runtimes)."""127    req = set(m["requires"].get("runtimes") or [])128    req.add("pm2")129    req.discard("ngrok")130    blob = json.dumps(m["processes"]) + json.dumps(m.get("launchd", []))131    if "/opt/homebrew/bin/node" in blob or "next" in blob or "npm" in blob or ".mjs" in blob or ".js" in blob:132        req.add("node")133    if "pnpm" in blob:134        req.add("pnpm")135    vp = m.get("venv_python") or ""136    for ver in ("3.14", "3.13", "3.12"):137        if "python@%s" % ver in vp or "/%s/" % ver in vp or "python%s" % ver in vp:138            req.add("python@%s" % ver)139    if "uv/python/cpython-3.12" in vp:140        req.discard("python@3.12")141        req.add("uv-python@3.12")142    m["requires"]["runtimes"] = sorted(req)143    return m144