main.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. """Fabric Node Agent — module discovery, GPU monitoring, systemd lifecycle, heartbeat."""
  2. from __future__ import annotations
  3. import asyncio
  4. import logging
  5. import os
  6. import socket
  7. import subprocess
  8. import time
  9. from fastapi import FastAPI, HTTPException, Request, Response
  10. from fastapi.middleware.cors import CORSMiddleware
  11. import httpx
  12. from fabric.shared.schemas import Heartbeat, ModuleState
  13. from fabric.agent.discovery import scan_modules
  14. from fabric.agent.gpu_monitor import get_per_pid_vram, get_gpu_count
  15. from fabric.agent.systemd_wrap import SystemdWrapRuntime
  16. from fabric.agent.heartbeat import heartbeat_loop
  17. logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
  18. log = logging.getLogger("fabric.agent")
  19. NODE_ID = os.environ.get("FABRIC_NODE_ID", socket.gethostname())
  20. NODE_IP = os.environ.get("FABRIC_NODE_IP", "")
  21. AGENT_PORT = int(os.environ.get("FABRIC_AGENT_PORT", "7701"))
  22. HYPERVISOR = os.environ.get("FABRIC_HYPERVISOR", "unknown")
  23. app = FastAPI(title=f"Fabric Agent ({NODE_ID})", version="0.1.0")
  24. app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
  25. _runtimes: dict[str, SystemdWrapRuntime] = {}
  26. _start_time = time.time()
  27. def _detect_ip() -> str:
  28. if NODE_IP:
  29. return NODE_IP
  30. try:
  31. r = subprocess.run(["hostname", "-I"], capture_output=True, text=True, timeout=5)
  32. for ip in r.stdout.strip().split():
  33. if not ip.startswith("127."):
  34. return ip
  35. except Exception:
  36. pass
  37. return "127.0.0.1"
  38. def _get_gpu_info() -> dict:
  39. """Per-physical-GPU memory + telemetry via nvidia-smi (each GPU counted once,
  40. NOT per process). Returns {gpus, vram_total_mb, vram_free_mb, driver_version}.
  41. Robust if nvidia-smi is missing or fails -> zeros / empty list."""
  42. empty = {"gpus": [], "vram_total_mb": 0, "vram_free_mb": 0, "driver_version": ""}
  43. try:
  44. r = subprocess.run(
  45. ["nvidia-smi",
  46. "--query-gpu=index,name,memory.total,memory.used,utilization.gpu,temperature.gpu,power.draw,driver_version",
  47. "--format=csv,noheader,nounits"],
  48. capture_output=True, text=True, timeout=5,
  49. )
  50. except Exception as e:
  51. log.warning("nvidia-smi query failed: %s", e)
  52. return empty
  53. if r.returncode != 0:
  54. return empty
  55. def _to_int(s):
  56. try:
  57. return int(float(s))
  58. except (ValueError, TypeError):
  59. return None
  60. def _to_float(s):
  61. try:
  62. return float(s)
  63. except (ValueError, TypeError):
  64. return None
  65. gpus: list[dict] = []
  66. driver = ""
  67. for line in r.stdout.strip().splitlines():
  68. parts = [p.strip() for p in line.split(",")]
  69. if len(parts) < 4:
  70. continue
  71. idx = _to_int(parts[0])
  72. if idx is None:
  73. continue
  74. total = _to_int(parts[2]) or 0
  75. used = _to_int(parts[3]) or 0
  76. if len(parts) > 7 and parts[7]:
  77. driver = parts[7]
  78. gpus.append({
  79. "gpu_index": idx,
  80. "name": parts[1],
  81. "mem_total_mb": total,
  82. "mem_used_mb": used,
  83. "mem_free_mb": max(total - used, 0),
  84. "util_pct": _to_int(parts[4]) if len(parts) > 4 else None,
  85. "temp_c": _to_int(parts[5]) if len(parts) > 5 else None,
  86. "power_w": _to_float(parts[6]) if len(parts) > 6 else None,
  87. })
  88. return {
  89. "gpus": gpus,
  90. "vram_total_mb": sum(g["mem_total_mb"] for g in gpus),
  91. "vram_free_mb": sum(g["mem_free_mb"] for g in gpus),
  92. "driver_version": driver,
  93. }
  94. def _load_modules():
  95. global _runtimes
  96. manifests = scan_modules()
  97. for m in manifests:
  98. if m.runtime.value == "systemd-wrap" and m.systemd:
  99. _runtimes[m.name] = SystemdWrapRuntime(m)
  100. log.info("Loaded systemd-wrap: %s -> %s", m.name, m.systemd.systemd_unit)
  101. else:
  102. log.warning("Unsupported runtime %s for %s (v0: systemd-wrap only)", m.runtime, m.name)
  103. pid_vram = _get_pid_vram_map()
  104. for name, rt in _runtimes.items():
  105. rt.update_state(pid_vram)
  106. def _get_pid_vram_map() -> dict[int, int]:
  107. m: dict[int, int] = {}
  108. for p in get_per_pid_vram():
  109. m[p.pid] = m.get(p.pid, 0) + p.vram_mb
  110. return m
  111. def _build_heartbeat() -> Heartbeat:
  112. gpu_status = get_per_pid_vram()
  113. pid_vram = _get_pid_vram_map()
  114. modules = []
  115. for name, rt in _runtimes.items():
  116. rt.update_state(pid_vram)
  117. modules.append(rt.get_status())
  118. info = _get_gpu_info()
  119. return Heartbeat(
  120. node_id=NODE_ID, node_ip=_detect_ip(), agent_port=AGENT_PORT,
  121. hypervisor=HYPERVISOR, gpu_status=gpu_status, modules=modules,
  122. uptime_s=int(time.time() - _start_time),
  123. gpus=info["gpus"],
  124. vram_total_mb=info["vram_total_mb"],
  125. vram_free_mb=info["vram_free_mb"],
  126. driver_version=info["driver_version"],
  127. )
  128. @app.get("/health")
  129. async def health():
  130. return {"ok": True, "node_id": NODE_ID, "modules": len(_runtimes), "gpu_count": get_gpu_count(), "uptime_s": int(time.time() - _start_time)}
  131. @app.get("/modules")
  132. async def list_modules():
  133. pid_vram = _get_pid_vram_map()
  134. return {"modules": [rt.update_state(pid_vram) or rt.get_status().model_dump() for rt in _runtimes.values()]}
  135. @app.get("/gpu-status")
  136. async def gpu_status():
  137. from fabric.agent.gpu_monitor import get_local_gpu_summary
  138. summary = get_local_gpu_summary()
  139. info = _get_gpu_info()
  140. summary["gpus"] = info["gpus"]
  141. summary["vram_total_mb"] = info["vram_total_mb"]
  142. summary["vram_free_mb"] = info["vram_free_mb"]
  143. summary["driver_version"] = info["driver_version"]
  144. return summary
  145. @app.post("/load/{module_name}")
  146. async def load_module(module_name: str):
  147. rt = _runtimes.get(module_name)
  148. if not rt:
  149. raise HTTPException(404, f"Module '{module_name}' not found")
  150. ok = rt.start()
  151. if not ok:
  152. raise HTTPException(500, f"Failed to start '{module_name}'")
  153. warmup = rt.manifest.lifecycle.warmup_s
  154. if warmup > 0:
  155. await asyncio.sleep(min(warmup, 5))
  156. return {"ok": True, "module": module_name, "state": "loaded"}
  157. @app.post("/unload/{module_name}")
  158. async def unload_module(module_name: str):
  159. rt = _runtimes.get(module_name)
  160. if not rt:
  161. raise HTTPException(404, f"Module '{module_name}' not found")
  162. return {"ok": rt.stop(), "module": module_name, "state": "unloaded"}
  163. @app.post("/infer/{module_name}")
  164. async def infer_proxy(module_name: str, request: Request):
  165. rt = _runtimes.get(module_name)
  166. if not rt:
  167. raise HTTPException(404, f"Module '{module_name}' not found")
  168. if not rt.is_active():
  169. raise HTTPException(503, f"Module '{module_name}' not active")
  170. rt.record_infer()
  171. body = await request.body()
  172. headers = {k: v for k, v in request.headers.items() if k.lower() not in ("host", "content-length", "transfer-encoding")}
  173. async with httpx.AsyncClient(timeout=120) as client:
  174. try:
  175. resp = await client.request(
  176. method=rt.manifest.systemd.infer_method if rt.manifest.systemd else "POST",
  177. url=rt.infer_url, content=body, headers=headers,
  178. )
  179. return Response(content=resp.content, status_code=resp.status_code, headers=dict(resp.headers))
  180. except Exception as e:
  181. raise HTTPException(502, f"Proxy failed: {e}")
  182. @app.on_event("startup")
  183. async def startup():
  184. log.info("Fabric Agent starting: node_id=%s ip=%s", NODE_ID, _detect_ip())
  185. _load_modules()
  186. asyncio.create_task(heartbeat_loop(_build_heartbeat))
  187. log.info("Agent ready with %d modules", len(_runtimes))