"""Sélection du nœud le plus efficient pour une app (ou un lot d'apps : plan).""" from . import config, registry def eligible(node, m, projected, exclude=()): """Retourne (ok, raison).""" a = node["alias"] pin = m["placement"].get("pin") if node.get("role") != "worker" and not (pin == a): return False, "rôle %s" % node.get("role") if not node.get("online"): return False, "hors ligne" if a in config.RESERVED and pin != a: return False, "réservé : %s" % config.RESERVED[a] if a in exclude or a in (m["placement"].get("avoid") or []): return False, "exclu" if pin and pin != a: return False, "épinglé sur %s" % pin missing = [r for r in m["requires"]["runtimes"] if not node["runtimes"].get(r)] if missing: return False, "runtimes manquants : %s" % ",".join(missing) need = max(float(m["requires"].get("ram_gb") or 1), m.get("ram_mb_observed", 512) * 1.5 / 1024) free = node["free_gb"] - projected[a]["ram_gb"] if free - need < 2.0: return False, "RAM insuffisante (%.1f G libres, besoin %.1f G)" % (free, need) if node["disk_free_gb"] < (m.get("size_mb", 0) / 1024) * 1.5 + 20: return False, "disque insuffisant" busy = set(node.get("ports", [])) | projected[a]["ports"] if not _is_home(a, m): for p in m["requires"].get("ports") or []: if p in busy: return False, "port %s occupé" % p return True, "" def _is_home(alias, m): """Le nœud héberge déjà cette app (source d'import ou registre) : ses propres ports ne la bloquent pas.""" return alias == m.get("source_node") or alias == registry.node_of(m["app"]) STAY_BONUS = 0.05 # évite de déplacer des Go de données pour un gain de score marginal def score(node, m, projected): a = node["alias"] need = max(float(m["requires"].get("ram_gb") or 1), m.get("ram_mb_observed", 512) * 1.5 / 1024) free_after = node["free_gb"] - projected[a]["ram_gb"] - need ram_ratio = max(0.0, free_after / max(node["ram_gb"], 1)) idle = max(0.0, 1.0 - (node["load1"] + 0.5 * projected[a]["apps"]) / max(node["cores"], 1)) disk = min(1.0, node["disk_free_gb"] / 500.0) cores = min(1.0, node["cores"] / 32.0) hosted = len(registry.apps_on(a)) + projected[a]["apps"] s = 0.45 * ram_ratio + 0.25 * idle + 0.10 * disk + 0.15 * cores - 0.04 * hosted if m["placement"].get("prefer") == a: s += 0.15 if _is_home(a, m): s += STAY_BONUS return round(s, 4) def rank(m, scan, projected=None, exclude=()): projected = projected or empty_projection(scan) rows = [] for a, node in scan.items(): ok, why = eligible(node, m, projected, exclude) rows.append({"node": a, "ok": ok, "why": why, "score": score(node, m, projected) if ok else -1}) rows.sort(key=lambda r: (-r["ok"], -r["score"])) return rows def empty_projection(scan): return {a: {"ram_gb": 0.0, "apps": 0, "ports": set()} for a in scan} def plan(manifests, scan, exclude=()): """Placement glouton : apps les plus lourdes d'abord, projection des ressources consommées.""" projected = empty_projection(scan) order = sorted(manifests.values(), key=lambda m: -(m.get("ram_mb_observed", 512) + m.get("size_mb", 0) / 20.0)) out = [] for m in order: rows = rank(m, scan, projected, exclude) best = rows[0] if rows and rows[0]["ok"] else None if best: a = best["node"] need = max(float(m["requires"].get("ram_gb") or 1), m.get("ram_mb_observed", 512) * 1.5 / 1024) projected[a]["ram_gb"] += need projected[a]["apps"] += 1 projected[a]["ports"] |= set(m["requires"].get("ports") or []) pin = m["placement"].get("pin") blockers = [] if not best: cand = [r for r in rows if not r["ok"] and (r["node"] == pin if pin else (scan[r["node"]].get("online") and scan[r["node"]].get("role") == "worker"))] cand.sort(key=lambda r: -(scan[r["node"]].get("ram_gb") or 0)) blockers = [(r["node"], r["why"]) for r in cand][:8] out.append({"app": m["app"], "node": best["node"] if best else None, "score": best["score"] if best else None, "current": registry.node_of(m["app"]) or m.get("source_node"), "pin": pin, "reason": m["placement"].get("reason", ""), "size_mb": m.get("size_mb", 0), "alternatives": [(r["node"], r["score"]) for r in rows[1:4] if r["ok"]], "blockers": blockers}) return out