227 lines
6.5 KiB
Python
227 lines
6.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Expose a small, sanitized snapshot of host CPU, memory, disk, and NVIDIA GPU usage."""
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
|
|
|
|
PORT = int(os.environ.get("PORT", "8092"))
|
|
HOST_PROC = Path(os.environ.get("HOST_PROC", "/host/proc"))
|
|
DISK_PATH = Path(os.environ.get("DISK_PATH", "/host/disk"))
|
|
CACHE_TTL_SECONDS = float(os.environ.get("CACHE_TTL_SECONDS", "2"))
|
|
|
|
cache_lock = threading.Lock()
|
|
cached_snapshot = None
|
|
cached_at = 0.0
|
|
previous_cpu = None
|
|
|
|
|
|
def read_text(path):
|
|
return path.read_text(encoding="utf-8", errors="replace")
|
|
|
|
|
|
def read_cpu_times():
|
|
first_line = read_text(HOST_PROC / "stat").splitlines()[0]
|
|
values = [int(value) for value in first_line.split()[1:]]
|
|
idle = values[3] + (values[4] if len(values) > 4 else 0)
|
|
return sum(values), idle
|
|
|
|
|
|
def cpu_usage_percent():
|
|
global previous_cpu
|
|
|
|
current = read_cpu_times()
|
|
if previous_cpu is None:
|
|
previous_cpu = current
|
|
time.sleep(0.12)
|
|
current = read_cpu_times()
|
|
|
|
total_delta = current[0] - previous_cpu[0]
|
|
idle_delta = current[1] - previous_cpu[1]
|
|
previous_cpu = current
|
|
if total_delta <= 0:
|
|
return 0.0
|
|
return round(max(0.0, min(100.0, (1 - idle_delta / total_delta) * 100)), 1)
|
|
|
|
|
|
def cpu_info():
|
|
cpuinfo = read_text(HOST_PROC / "cpuinfo")
|
|
model = "Unknown CPU"
|
|
logical_cores = 0
|
|
for line in cpuinfo.splitlines():
|
|
if line.startswith("processor"):
|
|
logical_cores += 1
|
|
elif line.startswith("model name") and model == "Unknown CPU":
|
|
model = line.split(":", 1)[1].strip()
|
|
|
|
load_values = read_text(HOST_PROC / "loadavg").split()
|
|
return {
|
|
"usagePercent": cpu_usage_percent(),
|
|
"logicalCores": logical_cores,
|
|
"model": model,
|
|
"load1": float(load_values[0]),
|
|
"load5": float(load_values[1]),
|
|
"load15": float(load_values[2]),
|
|
}
|
|
|
|
|
|
def memory_info():
|
|
values = {}
|
|
for line in read_text(HOST_PROC / "meminfo").splitlines():
|
|
key, raw_value = line.split(":", 1)
|
|
values[key] = int(raw_value.strip().split()[0]) * 1024
|
|
|
|
total = values["MemTotal"]
|
|
available = values["MemAvailable"]
|
|
used = total - available
|
|
return {
|
|
"usedBytes": used,
|
|
"availableBytes": available,
|
|
"totalBytes": total,
|
|
"usagePercent": round(used / total * 100, 1),
|
|
}
|
|
|
|
|
|
def root_device():
|
|
for line in read_text(HOST_PROC / "1" / "mountinfo").splitlines():
|
|
fields = line.split()
|
|
if len(fields) < 10 or fields[4] != "/" or "-" not in fields:
|
|
continue
|
|
separator = fields.index("-")
|
|
if len(fields) > separator + 2:
|
|
return fields[separator + 2]
|
|
return "root filesystem"
|
|
|
|
|
|
def disk_info():
|
|
stats = os.statvfs(DISK_PATH)
|
|
total = stats.f_blocks * stats.f_frsize
|
|
free = stats.f_bfree * stats.f_frsize
|
|
available = stats.f_bavail * stats.f_frsize
|
|
used = total - free
|
|
return {
|
|
"device": root_device(),
|
|
"mount": "/",
|
|
"usedBytes": used,
|
|
"availableBytes": available,
|
|
"totalBytes": total,
|
|
"usagePercent": round(used / total * 100, 1),
|
|
}
|
|
|
|
|
|
def optional_float(value):
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def gpu_info():
|
|
query = ",".join([
|
|
"index",
|
|
"name",
|
|
"utilization.gpu",
|
|
"memory.used",
|
|
"memory.total",
|
|
"temperature.gpu",
|
|
"power.draw",
|
|
"power.limit",
|
|
])
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
[
|
|
"nvidia-smi",
|
|
f"--query-gpu={query}",
|
|
"--format=csv,noheader,nounits",
|
|
],
|
|
capture_output=True,
|
|
check=True,
|
|
text=True,
|
|
timeout=5,
|
|
)
|
|
except (FileNotFoundError, subprocess.SubprocessError):
|
|
return {"available": False, "devices": []}
|
|
|
|
devices = []
|
|
for line in result.stdout.splitlines():
|
|
fields = [field.strip() for field in line.split(",")]
|
|
if len(fields) != 8:
|
|
continue
|
|
devices.append({
|
|
"index": int(fields[0]),
|
|
"name": fields[1],
|
|
"utilizationPercent": optional_float(fields[2]),
|
|
"memoryUsedMiB": optional_float(fields[3]),
|
|
"memoryTotalMiB": optional_float(fields[4]),
|
|
"temperatureCelsius": optional_float(fields[5]),
|
|
"powerDrawWatts": optional_float(fields[6]),
|
|
"powerLimitWatts": optional_float(fields[7]),
|
|
})
|
|
|
|
return {"available": bool(devices), "devices": devices}
|
|
|
|
|
|
def host_name():
|
|
hostname_path = Path("/host/hostname")
|
|
if hostname_path.exists():
|
|
return read_text(hostname_path).strip()
|
|
return "homelab"
|
|
|
|
|
|
def collect_snapshot():
|
|
global cached_snapshot, cached_at
|
|
|
|
now = time.monotonic()
|
|
with cache_lock:
|
|
if cached_snapshot is not None and now - cached_at < CACHE_TTL_SECONDS:
|
|
return cached_snapshot
|
|
|
|
cached_snapshot = {
|
|
"generatedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
"hostname": host_name(),
|
|
"cpu": cpu_info(),
|
|
"memory": memory_info(),
|
|
"disk": disk_info(),
|
|
"gpu": gpu_info(),
|
|
}
|
|
cached_at = time.monotonic()
|
|
return cached_snapshot
|
|
|
|
|
|
class MetricsHandler(BaseHTTPRequestHandler):
|
|
def send_json(self, status_code, payload):
|
|
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode()
|
|
self.send_response(status_code)
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.send_header("X-Content-Type-Options", "nosniff")
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def do_GET(self):
|
|
if self.path == "/healthz":
|
|
self.send_json(200, {"status": "ok"})
|
|
return
|
|
if self.path != "/metrics":
|
|
self.send_json(404, {"error": "Not found"})
|
|
return
|
|
|
|
try:
|
|
self.send_json(200, collect_snapshot())
|
|
except Exception as error:
|
|
print(f"metrics collection failed: {error}", flush=True)
|
|
self.send_json(503, {"error": "Metrics unavailable"})
|
|
|
|
def log_message(self, message_format, *args):
|
|
return
|
|
|
|
|
|
ThreadingHTTPServer(("0.0.0.0", PORT), MetricsHandler).serve_forever()
|