SPB Git
14.1 KB · 296 lines python
1"""`mld heal` : auto-réparation du cluster depuis la passerelle (toutes les 5 min via le LaunchDaemon io.maclustr.mld-heal).23Pour chaque nœud qui héberge des apps du registre, UN seul SSH ramène : uptime, démon PM2, statut de chaque processus,4LaunchAgents chargés, interface WireGuard wg1, code HTTP local de chaque app. Puis, seulement si quelque chose manque :5  - démon PM2 mort ou processus absents  → `pm2 resurrect` puis `deploy.start(app)` (régénère l'ecosystem, idempotent)6  - processus errored/stopped (hors cron) → `pm2 restart`7  - LaunchAgent absent                   → re-bootstrap du plist (ou start complet s'il n'existe plus)8  - wg1 tombé                            → kickstart du LaunchDaemon (sinon raccordement complet)9  - route Caddy absente / mauvais nœud   → `tunnelctl add` (via tunnel.ensure_route)10Garde-fous : nœud redémarré depuis < 3 min ignoré (pm2 resurrect fait son travail), au plus 6 actions par app et par heure,11registre sauvegardé UNE fois (diffusion aux abonnés) et seulement si un état change. Journal : ~/dispatch/logs/heal.log.12"""13import json14import os15import plistlib16import shlex17import time18from . import config, deploy, manifest, nodes, registry, ssh, tunnel1920GRACE_S = 18021MAX_ACTIONS_PER_HOUR = 622HEAL_LABEL = "io.maclustr.mld-heal"23HEAL_INTERVAL_S = 300242526def _now():27    return time.strftime("%Y-%m-%d %H:%M:%S")282930def _log(msg, quiet=False):31    line = "%s %s" % (_now(), msg)32    if not quiet:33        print(msg, flush=True)34    with open(os.path.join(config.LOG_DIR, "heal.log"), "a") as f:35        f.write(line + "\n")363738def _state():39    if os.path.exists(config.HEAL_STATE):40        try:41            return json.load(open(config.HEAL_STATE))42        except Exception:43            pass44    return {"actions": {}, "down": {}}454647def _save_state(st):48    tmp = config.HEAL_STATE + ".tmp"49    json.dump(st, open(tmp, "w"), indent=1)50    os.replace(tmp, config.HEAL_STATE)515253def _allowed(st, app):54    cut = time.time() - 360055    acts = [t for t in st["actions"].get(app, []) if t > cut]56    st["actions"][app] = acts57    return len(acts) < MAX_ACTIONS_PER_HOUR585960def _record(st, app):61    st["actions"].setdefault(app, []).append(time.time())626364def _node_report(ip, apps):65    """Un SSH : BOOT/NOW, PM2 up|down, PROC nom statut restarts, LA label, WG1 up|down, HUB ok|ko, HTTP app code."""66    script = r'''67echo "BOOT $(sysctl -n kern.boottime | sed -E 's/.*sec = ([0-9]+).*/\1/')"68echo "NOW $(date +%s)"69if pm2 ping >/dev/null 2>&1; then echo "PM2 up"; else echo "PM2 down"; fi70pm2 jlist 2>/dev/null | python3 -c "71import json,sys72try: L=json.load(sys.stdin)73except Exception: L=[]74for p in L: print('PROC', p['name'], p['pm2_env'].get('status'), p['pm2_env'].get('restart_time',0))" 2>/dev/null75launchctl list 2>/dev/null | awk 'NR>1{print "LA", $3}'76ifconfig 2>/dev/null | grep -q 'inet 10\.67\.0\.' && echo "WG1 up" || echo "WG1 down"77ping -c 1 -W 2000 10.67.0.1 >/dev/null 2>&1 && echo "HUB ok" || echo "HUB ko"78'''79    for app, m in apps.items():80        if m.get("port"):81            script += "echo \"HTTP %s $(curl -s -o /dev/null -m 6 -w '%%{http_code}' http://127.0.0.1:%s%s)\"\n" % (app, m["port"], m.get("health_path") or "/")82    rc, out, err = ssh.run(ip, script, timeout=90)83    if rc != 0 and "BOOT" not in out:84        return None85    rep = {"pm2": None, "procs": {}, "la": set(), "wg1": None, "hub": None, "http": {}, "boot": 0, "now": 0}86    for line in out.splitlines():87        p = line.split()88        if not p:89            continue90        if p[0] == "BOOT" and len(p) > 1 and p[1].isdigit():91            rep["boot"] = int(p[1])92        elif p[0] == "NOW" and len(p) > 1:93            rep["now"] = int(p[1])94        elif p[0] == "PM2":95            rep["pm2"] = p[1] == "up"96        elif p[0] == "PROC" and len(p) >= 3:97            rep["procs"][p[1]] = (p[2], int(p[3]) if len(p) > 3 and p[3].isdigit() else 0)98        elif p[0] == "LA" and len(p) > 1:99            rep["la"].add(p[1])100        elif p[0] == "WG1":101            rep["wg1"] = p[1] == "up"102        elif p[0] == "HUB":103            rep["hub"] = p[1] == "ok"104        elif p[0] == "HTTP" and len(p) >= 2:105            rep["http"][p[1]] = p[2] if len(p) > 2 else "000"106    rep["uptime"] = (rep["now"] - rep["boot"]) if rep["boot"] and rep["now"] else 10 ** 6107    return rep108109110def _http_ok(code):111    return code.isdigit() and code != "000" and int(code) < 500112113114def heal(apps_filter=None, nodes_filter=None, dry_run=False, quiet=False, public=True):115    t0 = time.time()116    st = _state()117    reg = registry.load()118    by_node = {}119    for app, v in reg["apps"].items():120        if apps_filter and app not in apps_filter:121            continue122        if not v.get("node"):123            continue124        if nodes_filter and v["node"] not in nodes_filter:125            continue126        by_node.setdefault(v["node"], {})[app] = manifest.load(app)127    changes = {}128    summary = {"ok": 0, "fixed": 0, "ko": 0, "skipped": 0}129    tun_state = None130    try:131        tun_state = tunnel.state()132    except tunnel.TunnelError as e:133        _log("tunnel : %s" % e, quiet)134    for alias in sorted(by_node):135        apps = by_node[alias]136        ip = nodes.ip_of(alias, rediscover=False)137        if not ip:138            # une seule redécouverte par passage139            ip = nodes.ip_of(alias, rediscover=True)140        if not ip:141            since = st["down"].setdefault(alias, _now())142            _log("%s : INJOIGNABLE (depuis %s) — %d app(s) : %s" % (alias, since, len(apps), ", ".join(sorted(apps))), quiet)143            for app in apps:144                if reg["apps"][app].get("status") != "node-down":145                    changes[app] = {"status": "node-down", "health": "nœud %s injoignable" % alias}146            summary["ko"] += len(apps)147            continue148        if alias in st["down"]:149            _log("%s : de retour (était injoignable depuis %s)" % (alias, st["down"].pop(alias)), quiet)150        rep = _node_report(ip, apps)151        if rep is None:152            _log("%s : sonde impossible" % alias, quiet)153            summary["skipped"] += len(apps)154            continue155        if rep["uptime"] < GRACE_S:156            _log("%s : redémarré il y a %ds — délai de grâce, on repasse plus tard" % (alias, rep["uptime"]), quiet)157            summary["skipped"] += len(apps)158            continue159        # --- WireGuard vers la passerelle (seulement si le nœud expose un site)160        exposes = any(tunnel.spec(m, alias) for m in apps.values())161        if exposes and (rep["wg1"] is False or rep["hub"] is False):162            _log("%s : wg1 %s / hub %s" % (alias, "up" if rep["wg1"] else "DOWN", "ok" if rep["hub"] else "KO"), quiet)163            if not dry_run:164                try:165                    tunnel.ensure_peer(alias, log=lambda s: _log(s, quiet), st=tun_state)166                except tunnel.TunnelError as e:167                    _log("  tunnel : %s" % e, quiet)168        resurrected = False169        for app in sorted(apps):170            m = apps[app]171            names = manifest.pm2_names(m)172            labels = manifest.launchd_labels(m)173            missing = [n for n in names if n not in rep["procs"]] if rep["pm2"] else list(names)174            bad = [n for n in names if n in rep["procs"] and rep["procs"][n][0] in ("errored", "stopped")175                   and next((p for p in m["processes"] if p["name"] == n), {}).get("autorestart", True)176                   and not next((p for p in m["processes"] if p["name"] == n), {}).get("cron_restart")]177            la_missing = [l for l in labels if l not in rep["la"]]178            code = rep["http"].get(app)179            http_ok = _http_ok(code) if m.get("port") else True180            problems = []181            if missing:182                problems.append("PM2 absent: %s" % ",".join(missing))183            if bad:184                problems.append("PM2 %s: %s" % (rep["procs"][bad[0]][0], ",".join(bad)))185            if la_missing:186                problems.append("launchd absent: %s" % ",".join(la_missing))187            if not http_ok and not missing and not bad:188                problems.append("HTTP local %s" % code)189            if not problems:190                summary["ok"] += 1191                if reg["apps"][app].get("status") != "online":192                    changes[app] = {"status": "online", "health": "local %s" % code if m.get("port") else "processus online"}193                # route publique : suit-elle bien ce nœud ?194                if tun_state and public and tunnel.spec(m, alias):195                    s = tunnel.spec(m, alias)196                    r = tunnel.routes(s["gateway"], tun_state).get(s["domain"])197                    if not tunnel._route_matches(r, s["upstreams"]):198                        _log("%s/%s : route https://%s %s" % (alias, app, s["domain"], "absente" if not r else "→ %s au lieu de %s" % (", ".join(u["addr"] for u in r.get("upstreams", [])), s["upstreams"][0])), quiet)199                        if not dry_run and _allowed(st, app):200                            try:201                                tunnel.ensure_route(m, alias, log=lambda x: _log(x, quiet), st=tun_state)202                                _record(st, app)203                                changes[app] = dict(changes.get(app, {}), tunnel={"gateway": s["gateway"], "domain": s["domain"], "upstream": s["upstreams"][0], "url": "https://%s" % s["domain"]})204                                summary["fixed"] += 1205                            except tunnel.TunnelError as e:206                                _log("  tunnel : %s" % e, quiet)207                continue208            _log("%s/%s : %s" % (alias, app, " ; ".join(problems)), quiet)209            if dry_run:210                summary["ko"] += 1211                continue212            if not _allowed(st, app):213                _log("  %s : trop d'actions dans l'heure (%d), on laisse tranquille" % (app, MAX_ACTIONS_PER_HOUR), quiet)214                summary["ko"] += 1215                changes[app] = {"status": "unhealthy", "health": " ; ".join(problems)}216                continue217            _record(st, app)218            try:219                if rep["pm2"] is False and not resurrected:220                    rc, out, _ = ssh.run(ip, "pm2 resurrect 2>&1 | tail -1", timeout=120)221                    _log("  pm2 resurrect sur %s : %s" % (alias, out.strip()[-100:]), quiet)222                    resurrected = True223                if missing or la_missing:224                    deploy.start(app, alias, m=m, ip=ip)225                elif bad:226                    rc, out, _ = ssh.run(ip, "pm2 restart %s --update-env >/dev/null 2>&1; pm2 save --force >/dev/null 2>&1; echo ok" % " ".join(bad), timeout=120)227                    _log("  pm2 restart %s : %s" % (",".join(bad), out.strip()[-40:]), quiet)228                elif not http_ok:229                    rc, out, _ = ssh.run(ip, "pm2 restart %s --update-env >/dev/null 2>&1; echo ok" % " ".join(names), timeout=120) if names else (0, "", "")230                    _log("  HTTP local %s → pm2 restart %s" % (code, ",".join(names)), quiet)231                ok, det = deploy.health(app, alias, ip=ip, m=m, timeout=45, public=False)232                tun = None233                if ok and tunnel.spec(m, alias):234                    tun = deploy._publish(app, alias, m, ip)235                    ok, det = deploy.health(app, alias, ip=ip, m=m, timeout=20, public=public and bool(tun))236                _log("  %s : %s (%s)" % (app, "RÉPARÉE" if ok else "toujours KO", det), quiet)237                fields = {"status": "online" if ok else "unhealthy", "health": det, "healed": _now()}238                if tun:239                    fields["tunnel"] = tun240                changes[app] = fields241                summary["fixed" if ok else "ko"] += 1242            except (deploy.DeployError, tunnel.TunnelError) as e:243                _log("  %s : ÉCHEC de réparation — %s" % (app, e), quiet)244                changes[app] = {"status": "unhealthy", "health": str(e)[:200]}245                summary["ko"] += 1246    if changes and not dry_run:247        registry.update_many(changes)248    _save_state(st)249    _log("heal terminé en %.0fs : %d ok, %d réparées, %d KO, %d ignorées (grâce/sonde)%s" % (250        time.time() - t0, summary["ok"], summary["fixed"], summary["ko"], summary["skipped"], " [dry-run]" if dry_run else ""), quiet)251    return summary252253254# ------------------------------------------------------------ LaunchDaemon sur la passerelle ---255256def plist_content():257    mld_bin = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "bin", "mld")258    d = {259        "Label": HEAL_LABEL,260        "ProgramArguments": [mld_bin, "heal", "--quiet"],261        "UserName": config.USER,262        "RunAtLoad": True,263        "StartInterval": HEAL_INTERVAL_S,264        "EnvironmentVariables": {"HOME": config.HOME, "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", "LANG": "fr_CA.UTF-8"},265        "WorkingDirectory": config.HOME,266        "StandardOutPath": os.path.join(config.LOG_DIR, "heal.out"),267        "StandardErrorPath": os.path.join(config.LOG_DIR, "heal.err"),268        "ProcessType": "Background",269        "LowPriorityIO": True,270    }271    return plistlib.dumps(d).decode()272273274def install(interval=None):275    """Installe (ou met à jour) le LaunchDaemon io.maclustr.mld-heal sur la passerelle (sudo via ~/dispatch/.sudo)."""276    import subprocess277    global HEAL_INTERVAL_S278    if interval:279        HEAL_INTERVAL_S = int(interval)280    pwf = config.SUDO_PW_FILE281    pw = open(pwf).read().strip() if os.path.exists(pwf) else None282    if not pw:283        raise SystemExit("mot de passe sudo absent : %s" % pwf)284    tmp = "/tmp/%s.plist" % HEAL_LABEL285    open(tmp, "w").write(plist_content())286    dest = "/Library/LaunchDaemons/%s.plist" % HEAL_LABEL287    sh = ("echo %s | sudo -S -p '' sh -c 'install -m 644 -o root -g wheel %s %s && launchctl bootout system/%s 2>/dev/null; "288          "launchctl bootstrap system %s && launchctl print system/%s | grep -E \"state|interval\" | head -2'") % (289        shlex.quote(pw), tmp, dest, HEAL_LABEL, dest, HEAL_LABEL)290    p = subprocess.run(["bash", "-c", sh], capture_output=True, text=True)291    print(p.stdout.strip() or p.stderr.strip()[-300:])292    os.remove(tmp)293    ok = p.returncode == 0294    print("LaunchDaemon %s : %s (toutes les %d s, journal %s/heal.log)" % (HEAL_LABEL, "installé" if ok else "ÉCHEC", HEAL_INTERVAL_S, config.LOG_DIR))295    return ok296
Files · 3918d4dc10129bd59f9e08517b475ba71e8e9f0e
Menu

Theme