"""Découverte LAN des nœuds + sonde de ressources/runtimes (scan).""" import json import os import time from concurrent.futures import ThreadPoolExecutor from . import config, ssh RUNTIME_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()) def _probe_script(): # sonde 100 % shell (les minis sans Xcode CLT n'ont pas de python3 fonctionnel) : lignes "KV clé valeur" sh = r''' export LC_ALL=C echo "KV host $(hostname -s)" echo "KV marker $(cat ~/.maclustr-node 2>/dev/null)" echo "KV ip $(ifconfig | awk '/inet 192\.168\.2\./{print $2; exit}')" echo "KV os $(sw_vers -productVersion)" echo "KV cores $(sysctl -n hw.ncpu)" echo "KV ram_gb $(( $(sysctl -n hw.memsize) / 1073741824 ))" echo "KV load1 $(sysctl -n vm.loadavg | tr -d '{}' | awk '{print $1}' | tr ',' '.')" echo "KV free_gb $(vm_stat | awk '/Pages (free|inactive|speculative|purgeable)/{gsub("\\.","",$NF); s+=$NF} END{printf "%.1f", s*16384/1073741824}')" echo "KV disk_free_gb $(df -g / | awk 'NR==2{print $4}')" echo "KV boot $(sysctl -n kern.boottime | sed -E 's/.*sec = ([0-9]+).*/\1/')" echo "KV ports $(lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | awk 'NR>1{n=split($9,a,":"); print a[n]}' | sort -un | tr '\n' ',')" echo "KV pm2 $(pm2 jlist 2>/dev/null | tr -d '\n' | grep -o '"name":"[^"]*"' | cut -d'"' -f4 | sort -u | tr '\n' ',')" ''' return sh + "\n" + RUNTIME_PROBE + "\n" def load_lan(): if os.path.exists(config.LAN_CACHE): return json.load(open(config.LAN_CACHE)) return {"updated": None, "ips": {}} def save_lan(d): d["updated"] = time.strftime("%Y-%m-%dT%H:%M:%S") json.dump(d, open(config.LAN_CACHE, "w"), indent=2) def _identify(ip): rc, out, _ = ssh.run(ip, "cat ~/.maclustr-node 2>/dev/null || hostname -s", timeout=12, path=False) if rc == 0 and out.strip(): return ip, out.strip().splitlines()[0].strip() return ip, None def discover(rng=range(1, 255), verbose=True): """Balaye 192.168.2.0/24 en SSH (clé passerelle) et lit le marqueur ~/.maclustr-node ; ajoute les REMOTE_NODES joignables.""" ips = [config.LAN_PREFIX + str(i) for i in rng] found = {} with ThreadPoolExecutor(max_workers=48) as ex: for ip, alias in ex.map(_identify, ips): if alias: # normalise l'alias (le marqueur est la source de vérité, sinon hostname) for a in config.NODES: if a.lower() == alias.lower(): alias = a break found[alias] = ip # nœuds hors LAN : connexion directe à l'IP publique, le marqueur doit confirmer l'alias for alias, r in config.REMOTE_NODES.items(): _, seen = _identify(r["host"]) if seen and seen.lower() == alias.lower(): found[alias] = r["host"] lan = load_lan() lan["ips"] = found save_lan(lan) if verbose: for a in sorted(found, key=lambda x: list(config.NODES).index(x) if x in config.NODES else 99): print(" %-8s %s" % (a, found[a])) missing = [a for a in config.NODES if a not in found] if missing: print(" hors LAN / éteints : %s" % ", ".join(missing)) return found def ip_of(alias, rediscover=True): lan = load_lan() ip = lan["ips"].get(alias) if ip and ssh.ok(ip, "true", timeout=15): return ip if rediscover: found = discover(verbose=False) return found.get(alias) return ip def probe(alias, ip): rc, out, err = ssh.run(ip, _probe_script(), timeout=90) info = {"alias": alias, "ip": ip, "online": False} if "KV cores" not in out: info["error"] = (err or out).strip()[:200] return info rts = {} kv = {} for line in out.splitlines(): if line.startswith("KV "): parts = line.split(" ", 2) kv[parts[1]] = parts[2].strip() if len(parts) > 2 else "" elif line.startswith("RT "): _, name, val = line.split(" ", 2) rts[name] = val.strip() == "1" def num(k, f=float, d=0): try: return f(kv.get(k, "") or d) except Exception: return d info.update({"host": kv.get("host", ""), "marker": kv.get("marker", ""), "os": kv.get("os", ""), "cores": num("cores", int), "ram_gb": num("ram_gb", int), "load1": num("load1"), "free_gb": num("free_gb"), "disk_free_gb": num("disk_free_gb", int)}) if kv.get("ip"): info["ip"] = kv["ip"] try: info["uptime_h"] = round((time.time() - int(kv.get("boot") or 0)) / 3600, 1) if kv.get("boot") else None except Exception: info["uptime_h"] = None info["ports"] = sorted(set(int(p) for p in kv.get("ports", "").split(",") if p.isdigit())) info["pm2"] = [p for p in kv.get("pm2", "").split(",") if p] info["runtimes"] = rts info["online"] = info["cores"] > 0 info.update({k: v for k, v in config.NODES.get(alias, {}).items() if k not in info or not info.get(k)}) info["role"] = config.ROLES.get(alias, "worker") return info def scan(aliases=None, verbose=True): lan = load_lan() if not lan["ips"]: discover(verbose=False) lan = load_lan() targets = [(a, ip) for a, ip in lan["ips"].items() if not aliases or a in aliases] # scan partiel (`mld scan X`) : on FUSIONNE dans le cache existant au lieu de marquer les autres nœuds hors ligne # (sinon `mld deploy` juste après voyait tout le cluster « hors ligne ») res = load_scan() if aliases else {} with ThreadPoolExecutor(max_workers=24) as ex: for info in ex.map(lambda t: probe(*t), targets): res[info["alias"]] = info for a in config.NODES: if a not in res: res[a] = {"alias": a, "online": False, "role": config.ROLES[a], "runtimes": {}, **config.NODES[a]} json.dump({"updated": time.strftime("%Y-%m-%dT%H:%M:%S"), "nodes": res}, open(config.SCAN_CACHE, "w"), indent=2) if verbose: print_scan(res) return res def load_scan(): if os.path.exists(config.SCAN_CACHE): return json.load(open(config.SCAN_CACHE))["nodes"] return {} def print_scan(res): print("%-8s %-6s %-16s %5s %6s %6s %6s %7s %4s %s" % ("nœud", "rôle", "ip", "cœurs", "RAM", "libre", "load", "disque", "pm2", "runtimes")) for a in config.NODES: n = res.get(a, {}) if not n.get("online"): print("%-8s %-6s %-16s — hors ligne" % (a, n.get("role", ""), n.get("ip", ""))) continue rt = ",".join(k.replace("python@", "py").replace("uv-python@", "uvpy") for k, v in sorted(n["runtimes"].items()) if v) 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))