""" 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), "runtime": n.get("runtime", CONFIG.get("default_runtime", "host")), "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, # Runtime-specific fields "container": n.get("container"), "vm": n.get("vm"), "firecracker": n.get("firecracker"), "arch": n.get("arch"), } # ----------------------------- # 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, } # ----------------------------- # RUNTIME STATUS # ----------------------------- @router.get("/runtimes") def runtime_status(): """Return availability status for all supported runtimes. This endpoint is used by sorcery-go's FesterClient to discover which runtimes are available on the Fester master, so it can align its own runtime selection with Fester's capabilities. """ import shutil runtimes = { "host": {"available": True, "note": "always available"}, } # LXC runtimes["lxc"] = { "available": shutil.which("lxc") is not None, "note": "LXC containers via lxc-tools", } # Podman podman_status = _check_podman() runtimes["podman"] = podman_status # Firecracker fc_status = _check_firecracker() runtimes["firecracker"] = fc_status # libvirt runtimes["libvirt"] = { "available": shutil.which("virsh") is not None, "note": "libvirt VMs via virsh", } # tmux runtimes["tmux"] = { "available": shutil.which("tmux") is not None, "note": "detached sessions for long-running builds", } return { "default_runtime": CONFIG.get("default_runtime", "host"), "runtimes": runtimes, } def _check_podman() -> dict: """Check Podman availability and version.""" import subprocess if shutil.which("podman") is None: return {"available": False, "note": "podman not found on PATH"} try: result = subprocess.run( ["podman", "--version"], capture_output=True, text=True, timeout=5, ) return { "available": True, "version": result.stdout.strip(), "note": "rootless OCI containers", } except Exception as e: return {"available": True, "note": str(e)} def _check_firecracker() -> dict: """Check Firecracker availability.""" import subprocess, os fc_bin = os.environ.get("FESTER_FIRECRACKER_BIN", "firecracker") if shutil.which(fc_bin) is None: return {"available": False, "note": f"{fc_bin} not found on PATH"} try: result = subprocess.run( [fc_bin, "--version"], capture_output=True, text=True, timeout=5, ) cfg = CONFIG.get("firecracker", {}) return { "available": True, "version": result.stdout.strip(), "note": "AWS microVM hypervisor", "kernel": cfg.get("kernel", ""), "rootfs": cfg.get("rootfs", ""), "socket_dir": cfg.get("socket_dir", "/run/fester/firecracker"), } except Exception as e: return {"available": True, "note": str(e)} # ----------------------------- # LEGACY WRAPPER FOR API.PY FACADE # ----------------------------- def nodes_endpoint(registry=None): """Legacy entrypoint — returns the same data as GET /api/nodes.""" return list_nodes()