main.py 7.0 KB

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