212 lines
6.9 KiB
Python
212 lines
6.9 KiB
Python
"""
|
|
Nodes API — list nodes, get/set node roles + policies, probe agents.
|
|
"""
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel
|
|
from typing import Optional, Dict, Any
|
|
|
|
from backend.nodes.roles_store import load_roles, save_roles, get_role, DEFAULT_ROLE
|
|
from backend.nodes.roles import NodeRole
|
|
from backend.nodes.probe import probe_async, normalize
|
|
from backend.config import CONFIG
|
|
|
|
router = APIRouter(prefix="/api/nodes", tags=["nodes"])
|
|
|
|
|
|
class PolicyBody(BaseModel):
|
|
policy: Optional[str] = None # preferred | avoid | neutral
|
|
compile_weight: Optional[float] = None
|
|
cache_writer: Optional[bool] = None
|
|
cache_reader: Optional[bool] = None
|
|
max_thermal_state: Optional[float] = None
|
|
distcc_enabled: Optional[bool] = None
|
|
|
|
|
|
def _registry():
|
|
"""Lazy import of the singleton registry from api.api."""
|
|
from backend.api.api import registry
|
|
return registry
|
|
|
|
|
|
def _node_view(n: dict, role: NodeRole) -> dict:
|
|
"""Combine config + role + live registry state into a single view."""
|
|
name = n["name"]
|
|
reg = _registry().get(name)
|
|
return {
|
|
"name": name,
|
|
"host": n.get("host"),
|
|
"max_jobs": n.get("max_jobs", 1),
|
|
"state": "online" if (reg is None or not getattr(reg, "offline", False)) else "offline",
|
|
"policy": _policy_from_role(role),
|
|
"role": _role_summary(role),
|
|
# Live metrics from the registry (may be None if no agent has reported)
|
|
"heat": getattr(reg, "temp", None) if reg else None,
|
|
"jobs": getattr(reg, "active_jobs", None) if reg else None,
|
|
"cpu_load": getattr(reg, "cpu_load", None) if reg else None,
|
|
"memory_load": getattr(reg, "memory_load", None) if reg else None,
|
|
"instability": getattr(reg, "instability", None) if reg else None,
|
|
"last_seen": getattr(reg, "last_seen", None) if reg else None,
|
|
}
|
|
|
|
|
|
# -----------------------------
|
|
# LIST NODES
|
|
# -----------------------------
|
|
@router.get("")
|
|
def list_nodes():
|
|
"""List configured nodes with their current role + policy + live metrics."""
|
|
roles = load_roles()
|
|
nodes = [_node_view(n, roles.get(n["name"], DEFAULT_ROLE)) for n in CONFIG.get("nodes", [])]
|
|
return {"master": CONFIG.get("master"), "nodes": nodes}
|
|
|
|
|
|
# -----------------------------
|
|
# GET ONE NODE
|
|
# -----------------------------
|
|
@router.get("/{name}")
|
|
def get_node(name: str):
|
|
roles = load_roles()
|
|
for n in CONFIG.get("nodes", []):
|
|
if n["name"] == name:
|
|
return _node_view(n, roles.get(name, DEFAULT_ROLE))
|
|
return {"error": "not found"}
|
|
|
|
|
|
# -----------------------------
|
|
# SET NODE POLICY
|
|
# -----------------------------
|
|
@router.post("/{name}/policy")
|
|
def set_node_policy(name: str, body: PolicyBody):
|
|
roles = load_roles()
|
|
role = roles.get(name, NodeRole(name=name))
|
|
|
|
if body.policy is not None:
|
|
# Translate policy keyword into compile_weight + thermal cap
|
|
if body.policy == "preferred":
|
|
role.compile_weight = 2.0
|
|
role.max_thermal_state = 0.95
|
|
elif body.policy == "avoid":
|
|
role.compile_weight = 0.2
|
|
role.max_thermal_state = 0.5
|
|
elif body.policy == "neutral":
|
|
role.compile_weight = 1.0
|
|
role.max_thermal_state = 0.85
|
|
|
|
if body.compile_weight is not None:
|
|
role.compile_weight = body.compile_weight
|
|
if body.cache_writer is not None:
|
|
role.cache_writer = body.cache_writer
|
|
if body.cache_reader is not None:
|
|
role.cache_reader = body.cache_reader
|
|
if body.max_thermal_state is not None:
|
|
role.max_thermal_state = body.max_thermal_state
|
|
if body.distcc_enabled is not None:
|
|
role.distcc_enabled = body.distcc_enabled
|
|
|
|
roles[name] = role
|
|
save_roles(roles)
|
|
return {"status": "ok", "node": name, "role": role.__dict__}
|
|
|
|
|
|
# -----------------------------
|
|
# PROBE A NODE (manual)
|
|
# -----------------------------
|
|
@router.post("/{name}/probe")
|
|
async def probe_node_endpoint(name: str):
|
|
"""Manually trigger a probe of the named node's agent.
|
|
Updates the registry and returns the normalized state."""
|
|
# Find the node config
|
|
cfg = None
|
|
for n in CONFIG.get("nodes", []):
|
|
if n["name"] == name:
|
|
cfg = n
|
|
break
|
|
if not cfg:
|
|
raise HTTPException(404, f"node {name} not configured")
|
|
|
|
host = cfg.get("host")
|
|
if not host:
|
|
return {"error": "node has no host configured", "node": name}
|
|
|
|
raw = await probe_async(host)
|
|
state = normalize(raw, name)
|
|
|
|
if raw is not None:
|
|
# Update the registry with the fresh probe data
|
|
reg = _registry()
|
|
reg.update(
|
|
name,
|
|
cpu_load=state["cpu_load"],
|
|
memory_load=state["memory_load"],
|
|
temp=state["temp"],
|
|
instability=state["instability"],
|
|
active_jobs=state["active_jobs"],
|
|
last_seen=state["last_seen"],
|
|
)
|
|
return {"status": "ok", "node": name, "source": "agent", "state": state, "raw": raw}
|
|
else:
|
|
return {"status": "unreachable", "node": name, "host": host, "state": state}
|
|
|
|
|
|
# -----------------------------
|
|
# PROBE ALL NODES
|
|
# -----------------------------
|
|
@router.post("/probe-all")
|
|
async def probe_all_nodes_endpoint():
|
|
"""Probe every configured node in parallel."""
|
|
import asyncio
|
|
cfg_nodes = CONFIG.get("nodes", [])
|
|
|
|
async def probe_one(n):
|
|
host = n.get("host")
|
|
if not host:
|
|
return {"node": n["name"], "host": None, "reachable": False,
|
|
"state": normalize(None, n["name"])}
|
|
raw = await probe_async(host)
|
|
state = normalize(raw, n["name"])
|
|
if raw is not None:
|
|
reg = _registry()
|
|
reg.update(
|
|
n["name"],
|
|
cpu_load=state["cpu_load"],
|
|
memory_load=state["memory_load"],
|
|
temp=state["temp"],
|
|
instability=state["instability"],
|
|
active_jobs=state["active_jobs"],
|
|
last_seen=state["last_seen"],
|
|
)
|
|
return {"node": n["name"], "host": host, "reachable": raw is not None, "state": state}
|
|
|
|
results = await asyncio.gather(*[probe_one(n) for n in cfg_nodes])
|
|
return {"results": list(results)}
|
|
|
|
|
|
# -----------------------------
|
|
# HELPERS
|
|
# -----------------------------
|
|
def _policy_from_role(role: NodeRole) -> str:
|
|
if role.compile_weight >= 2.0:
|
|
return "preferred"
|
|
if role.compile_weight <= 0.2:
|
|
return "avoid"
|
|
return "neutral"
|
|
|
|
|
|
def _role_summary(role: NodeRole) -> Dict[str, Any]:
|
|
return {
|
|
"compile_weight": role.compile_weight,
|
|
"cache_writer": role.cache_writer,
|
|
"cache_reader": role.cache_reader,
|
|
"distcc_enabled": role.distcc_enabled,
|
|
"max_thermal_state": role.max_thermal_state,
|
|
}
|
|
|
|
|
|
# -----------------------------
|
|
# Legacy wrapper for api.py facade
|
|
# -----------------------------
|
|
def nodes_endpoint(registry=None):
|
|
"""Legacy entrypoint — returns the same data as GET /api/nodes."""
|
|
return list_nodes()
|