systemd_wrap.py 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  1. """systemd-wrap: control existing systemd services as Fabric modules."""
  2. from __future__ import annotations
  3. import logging
  4. import subprocess
  5. import time
  6. from pathlib import Path
  7. import httpx
  8. from fabric.shared.schemas import ModuleManifest, ModuleState, ModuleStatus
  9. log = logging.getLogger("fabric.agent.systemd_wrap")
  10. class SystemdWrapRuntime:
  11. def __init__(self, manifest: ModuleManifest):
  12. self.manifest = manifest
  13. self.unit = manifest.systemd.systemd_unit if manifest.systemd else None
  14. self.health_url = manifest.systemd.health_url if manifest.systemd else None
  15. self.infer_url = manifest.systemd.infer_url if manifest.systemd else ""
  16. self._last_infer_at: float | None = None
  17. self._health_ok = False
  18. self._state = ModuleState.unloaded
  19. self._vram_mb = 0
  20. def _systemctl(self, action: str) -> tuple[int, str]:
  21. if not self.unit:
  22. return 1, "no systemd unit configured"
  23. try:
  24. r = subprocess.run(["systemctl", action, self.unit], capture_output=True, text=True, timeout=30)
  25. return r.returncode, r.stdout + r.stderr
  26. except Exception as e:
  27. return 1, str(e)
  28. def is_active(self) -> bool:
  29. code, out = self._systemctl("is-active")
  30. return code == 0 and "active" in out
  31. async def check_health(self) -> bool:
  32. if not self.health_url:
  33. return self.is_active()
  34. try:
  35. async with httpx.AsyncClient(timeout=5) as c:
  36. r = await c.get(self.health_url)
  37. self._health_ok = r.status_code < 500
  38. return self._health_ok
  39. except Exception:
  40. self._health_ok = False
  41. return False
  42. def start(self) -> bool:
  43. if self.is_active():
  44. return True
  45. log.info("Starting service %s", self.unit)
  46. code, out = self._systemctl("start")
  47. if code != 0:
  48. log.error("Failed to start %s: %s", self.unit, out)
  49. return code == 0
  50. def stop(self) -> bool:
  51. if not self.is_active():
  52. return True
  53. log.info("Stopping service %s", self.unit)
  54. code, out = self._systemctl("stop")
  55. if code != 0:
  56. log.error("Failed to stop %s: %s", self.unit, out)
  57. return code == 0
  58. def get_main_pid(self) -> int | None:
  59. if not self.unit:
  60. return None
  61. try:
  62. r = subprocess.run(["systemctl", "show", "-p", "MainPID", "--value", self.unit], capture_output=True, text=True, timeout=5)
  63. pid = int(r.stdout.strip())
  64. return pid if pid > 0 else None
  65. except Exception:
  66. return None
  67. def get_cgroup_pids(self) -> list[int]:
  68. """Return all PIDs in the unit's cgroup (main + workers)."""
  69. if not self.unit:
  70. return []
  71. try:
  72. r = subprocess.run(["systemctl", "show", "-p", "ControlGroup", "--value", self.unit], capture_output=True, text=True, timeout=5)
  73. cg = r.stdout.strip()
  74. if not cg:
  75. return []
  76. procs = Path(f"/sys/fs/cgroup{cg}/cgroup.procs")
  77. if not procs.exists():
  78. return []
  79. return [int(p) for p in procs.read_text().split() if p.strip()]
  80. except Exception:
  81. return []
  82. def update_state(self, per_pid_vram: dict[int, int] | None = None):
  83. if not self.is_active():
  84. self._state = ModuleState.unloaded
  85. self._vram_mb = 0
  86. return
  87. if per_pid_vram:
  88. cgroup_pids = self.get_cgroup_pids()
  89. total = sum(per_pid_vram.get(pid, 0) for pid in cgroup_pids)
  90. if total > 0:
  91. self._vram_mb = total
  92. elif not cgroup_pids:
  93. # cgroup unreadable — try MainPID as last resort
  94. pid = self.get_main_pid()
  95. self._vram_mb = per_pid_vram.get(pid, 0) if pid else 0
  96. else:
  97. self._vram_mb = 0
  98. else:
  99. self._vram_mb = 0
  100. self._state = ModuleState.loaded
  101. self._health_ok = True
  102. def record_infer(self):
  103. self._last_infer_at = time.time()
  104. def _required_vram_mb(self) -> int:
  105. """Declared manifest VRAM requirement as int (0 when "auto"/unset)."""
  106. req = self.manifest.resources.vram_mb
  107. return req if isinstance(req, int) else 0
  108. def get_status(self) -> ModuleStatus:
  109. return ModuleStatus(
  110. name=self.manifest.name, state=self._state, vram_mb=self._vram_mb,
  111. required_vram_mb=self._required_vram_mb(),
  112. last_infer_at=self._last_infer_at, health_ok=self._health_ok,
  113. infer_url=self.infer_url, modality=self.manifest.modality,
  114. aliases=self.manifest.aliases, priority_class=self.manifest.scheduling.priority_class,
  115. reclaim=self.manifest.lifecycle.reclaim,
  116. idle_evict_s=self.manifest.lifecycle.idle_evict_s,
  117. warm_default=self.manifest.lifecycle.warm_default,
  118. )