SPB Git forge
29commits 1branches 0releases
684.0 KBsize
maindefault branch
2 days agolast push
Python 95.8% Shell 4.2%
6.7 KB · 163 lines python
Raw Blame History
1"""Découverte LAN des nœuds + sonde de ressources/runtimes (scan)."""2import json3import os4import time5from concurrent.futures import ThreadPoolExecutor6from . import config, ssh78RUNTIME_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())91011def _probe_script():12    # sonde 100 % shell (les minis sans Xcode CLT n'ont pas de python3 fonctionnel) : lignes "KV clé valeur"13    sh = r'''14export LC_ALL=C15echo "KV host $(hostname -s)"16echo "KV marker $(cat ~/.maclustr-node 2>/dev/null)"17echo "KV ip $(ifconfig | awk '/inet 192\.168\.2\./{print $2; exit}')"18echo "KV os $(sw_vers -productVersion)"19echo "KV cores $(sysctl -n hw.ncpu)"20echo "KV ram_gb $(( $(sysctl -n hw.memsize) / 1073741824 ))"21echo "KV load1 $(sysctl -n vm.loadavg | tr -d '{}' | awk '{print $1}' | tr ',' '.')"22echo "KV free_gb $(vm_stat | awk '/Pages (free|inactive|speculative|purgeable)/{gsub("\\.","",$NF); s+=$NF} END{printf "%.1f", s*16384/1073741824}')"23echo "KV disk_free_gb $(df -g / | awk 'NR==2{print $4}')"24echo "KV boot $(sysctl -n kern.boottime | sed -E 's/.*sec = ([0-9]+).*/\1/')"25echo "KV ports $(lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | awk 'NR>1{n=split($9,a,":"); print a[n]}' | sort -un | tr '\n' ',')"26echo "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"293031def load_lan():32    if os.path.exists(config.LAN_CACHE):33        return json.load(open(config.LAN_CACHE))34    return {"updated": None, "ips": {}}353637def 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)404142def _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, None474849def discover(rng=range(1, 255), verbose=True):50    """Balaye 192.168.2.0/24 en SSH (clé passerelle) et lit le marqueur ~/.maclustr-node ; ajoute les REMOTE_NODES joignables."""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 = a60                        break61                found[alias] = ip62    # nœuds hors LAN : connexion directe à l'IP publique, le marqueur doit confirmer l'alias63    for alias, r in config.REMOTE_NODES.items():64        _, seen = _identify(r["host"])65        if seen and seen.lower() == alias.lower():66            found[alias] = r["host"]67    lan = load_lan()68    lan["ips"] = found69    save_lan(lan)70    if verbose:71        for a in sorted(found, key=lambda x: list(config.NODES).index(x) if x in config.NODES else 99):72            print("  %-8s %s" % (a, found[a]))73        missing = [a for a in config.NODES if a not in found]74        if missing:75            print("  hors LAN / éteints : %s" % ", ".join(missing))76    return found777879def ip_of(alias, rediscover=True):80    lan = load_lan()81    ip = lan["ips"].get(alias)82    if ip and ssh.ok(ip, "true", timeout=15):83        return ip84    if rediscover:85        found = discover(verbose=False)86        return found.get(alias)87    return ip888990def probe(alias, ip):91    rc, out, err = ssh.run(ip, _probe_script(), timeout=90)92    info = {"alias": alias, "ip": ip, "online": False}93    if "KV cores" not in out:94        info["error"] = (err or out).strip()[:200]95        return info96    rts = {}97    kv = {}98    for line in out.splitlines():99        if line.startswith("KV "):100            parts = line.split(" ", 2)101            kv[parts[1]] = parts[2].strip() if len(parts) > 2 else ""102        elif line.startswith("RT "):103            _, name, val = line.split(" ", 2)104            rts[name] = val.strip() == "1"105    def num(k, f=float, d=0):106        try:107            return f(kv.get(k, "") or d)108        except Exception:109            return d110    info.update({"host": kv.get("host", ""), "marker": kv.get("marker", ""), "os": kv.get("os", ""), "cores": num("cores", int),111                 "ram_gb": num("ram_gb", int), "load1": num("load1"), "free_gb": num("free_gb"), "disk_free_gb": num("disk_free_gb", int)})112    if kv.get("ip"):113        info["ip"] = kv["ip"]114    try:115        info["uptime_h"] = round((time.time() - int(kv.get("boot") or 0)) / 3600, 1) if kv.get("boot") else None116    except Exception:117        info["uptime_h"] = None118    info["ports"] = sorted(set(int(p) for p in kv.get("ports", "").split(",") if p.isdigit()))119    info["pm2"] = [p for p in kv.get("pm2", "").split(",") if p]120    info["runtimes"] = rts121    info["online"] = info["cores"] > 0122    info.update({k: v for k, v in config.NODES.get(alias, {}).items() if k not in info or not info.get(k)})123    info["role"] = config.ROLES.get(alias, "worker")124    return info125126127def scan(aliases=None, verbose=True):128    lan = load_lan()129    if not lan["ips"]:130        discover(verbose=False)131        lan = load_lan()132    targets = [(a, ip) for a, ip in lan["ips"].items() if not aliases or a in aliases]133    # scan partiel (`mld scan X`) : on FUSIONNE dans le cache existant au lieu de marquer les autres nœuds hors ligne134    # (sinon `mld deploy` juste après voyait tout le cluster « hors ligne »)135    res = load_scan() if aliases else {}136    with ThreadPoolExecutor(max_workers=24) as ex:137        for info in ex.map(lambda t: probe(*t), targets):138            res[info["alias"]] = info139    for a in config.NODES:140        if a not in res:141            res[a] = {"alias": a, "online": False, "role": config.ROLES[a], "runtimes": {}, **config.NODES[a]}142    json.dump({"updated": time.strftime("%Y-%m-%dT%H:%M:%S"), "nodes": res}, open(config.SCAN_CACHE, "w"), indent=2)143    if verbose:144        print_scan(res)145    return res146147148def load_scan():149    if os.path.exists(config.SCAN_CACHE):150        return json.load(open(config.SCAN_CACHE))["nodes"]151    return {}152153154def print_scan(res):155    print("%-8s %-6s %-16s %5s %6s %6s %6s %7s %4s  %s" % ("nœud", "rôle", "ip", "cœurs", "RAM", "libre", "load", "disque", "pm2", "runtimes"))156    for a in config.NODES:157        n = res.get(a, {})158        if not n.get("online"):159            print("%-8s %-6s %-16s   — hors ligne" % (a, n.get("role", ""), n.get("ip", "")))160            continue161        rt = ",".join(k.replace("python@", "py").replace("uv-python@", "uvpy") for k, v in sorted(n["runtimes"].items()) if v)162        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))163