fester/backend/api/nodes.py

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()