feat: publish CodexPi contest snapshot
Some checks are pending
Qualité / tests (push) Waiting to run
Some checks are pending
Qualité / tests (push) Waiting to run
This commit is contained in:
203
scripts/pi_doctor_collector.py
Normal file
203
scripts/pi_doctor_collector.py
Normal file
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Collect host health for Pi Doctor and upload it without privileged mutations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import psutil
|
||||
|
||||
|
||||
def utcnow() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def _temperature() -> float | None:
|
||||
try:
|
||||
values = psutil.sensors_temperatures()
|
||||
except (AttributeError, OSError):
|
||||
values = {}
|
||||
for entries in values.values():
|
||||
for entry in entries:
|
||||
if entry.current is not None:
|
||||
return round(float(entry.current), 1)
|
||||
thermal = Path("/sys/class/thermal/thermal_zone0/temp")
|
||||
try:
|
||||
return round(float(thermal.read_text(encoding="utf-8").strip()) / 1000, 1)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _run(command: list[str], timeout: float = 5) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(command, capture_output=True, text=True, timeout=timeout, check=False)
|
||||
|
||||
|
||||
def _docker_snapshot() -> dict[str, Any]:
|
||||
try:
|
||||
result = _run(["docker", "ps", "--all", "--format", "{{json .}}"])
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return {"available": False, "containers": [], "error": str(exc)[:300]}
|
||||
if result.returncode:
|
||||
return {"available": False, "containers": [], "error": result.stderr.strip()[:300]}
|
||||
containers = []
|
||||
for line in result.stdout.splitlines():
|
||||
try:
|
||||
item = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
containers.append({"name": item.get("Names", ""), "state": item.get("State", "unknown"), "status": item.get("Status", "")})
|
||||
return {"available": True, "containers": containers}
|
||||
|
||||
|
||||
def _mounts() -> list[dict[str, Any]]:
|
||||
rows = []
|
||||
for part in psutil.disk_partitions(all=True):
|
||||
if part.fstype.lower() not in {"cifs", "smb3"} and not part.mountpoint.startswith(("/media/", "/mnt/")):
|
||||
continue
|
||||
row: dict[str, Any] = {"path": part.mountpoint, "fstype": part.fstype, "state": "mounted"}
|
||||
try:
|
||||
row["percent"] = psutil.disk_usage(part.mountpoint).percent
|
||||
except OSError as exc:
|
||||
row.update(state="unavailable", error=str(exc)[:300])
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def _probe(name: str, url: str | None) -> dict[str, Any]:
|
||||
if not url:
|
||||
return {"configured": False}
|
||||
try:
|
||||
request = urllib.request.Request(url, method="HEAD", headers={"User-Agent": "CodexPi-PiDoctor/1"})
|
||||
with urllib.request.urlopen(request, timeout=4) as response:
|
||||
return {"configured": True, "available": response.status < 500, "status": response.status}
|
||||
except urllib.error.HTTPError as exc:
|
||||
return {"configured": True, "available": exc.code < 500, "status": exc.code}
|
||||
except (OSError, urllib.error.URLError) as exc:
|
||||
return {"configured": True, "available": False, "error": f"{name}: {exc}"[:300]}
|
||||
|
||||
|
||||
def collect(codexpi_url: str | None = None, jellyfin_url: str | None = None) -> dict[str, Any]:
|
||||
cpu = psutil.cpu_percent(interval=0.2)
|
||||
memory = psutil.virtual_memory().percent
|
||||
root = psutil.disk_usage("/").percent
|
||||
return {
|
||||
"system": {
|
||||
"timestamp": utcnow(), "hostname": socket.gethostname(), "cpu_percent": cpu,
|
||||
"memory_percent": memory, "temperature_c": _temperature(),
|
||||
"boot_time": datetime.fromtimestamp(psutil.boot_time(), UTC).isoformat(),
|
||||
"kernel_error_count": 0, "previous_boot_error_count": 0, "abnormal_restart_count": 0,
|
||||
},
|
||||
"storage": {"root_percent": root, "mounts": _mounts()},
|
||||
"docker": _docker_snapshot(),
|
||||
"network": {},
|
||||
"services": {"codexpi": _probe("CodexPi", codexpi_url), "jellyfin": _probe("Jellyfin", jellyfin_url)},
|
||||
}
|
||||
|
||||
|
||||
def detect_incidents(snapshot: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
system, storage = snapshot["system"], snapshot["storage"]
|
||||
now, rows = system["timestamp"], []
|
||||
|
||||
def add(key: str, severity: str, service: str, symptoms: str, details: dict[str, Any]) -> None:
|
||||
rows.append({"fingerprint": key, "source": "pi-doctor-collector", "severity": severity,
|
||||
"service": service, "symptoms": symptoms, "details": details,
|
||||
"first_seen": now, "last_seen": now})
|
||||
|
||||
for key, value, warning, critical, label in (
|
||||
("cpu-high", system["cpu_percent"], 90, 98, "CPU"),
|
||||
("memory-high", system["memory_percent"], 90, 97, "RAM"),
|
||||
("root-disk-high", storage["root_percent"], 85, 95, "Disque système"),
|
||||
):
|
||||
if value >= warning:
|
||||
add(key, "critical" if value >= critical else "warning", label, f"Utilisation élevée : {value:.1f} %", {"percent": value})
|
||||
temperature = system.get("temperature_c")
|
||||
if temperature is not None and temperature >= 75:
|
||||
add("temperature-high", "critical" if temperature >= 85 else "warning", "Température", f"Température élevée : {temperature:.1f} °C", {"temperature_c": temperature})
|
||||
if not snapshot["docker"].get("available"):
|
||||
add("docker-unavailable", "warning", "Docker", "Docker indisponible", {"error": snapshot["docker"].get("error")})
|
||||
for mount in storage["mounts"]:
|
||||
if mount.get("state") != "mounted":
|
||||
add(f"mount:{mount['path']}", "warning", mount["path"], "Montage SMB indisponible", mount)
|
||||
for name, state in snapshot["services"].items():
|
||||
if state.get("configured") and not state.get("available"):
|
||||
add(f"service:{name}", "warning", name, "Service configuré mais indisponible", state)
|
||||
return rows
|
||||
|
||||
|
||||
def _post(url: str, token: str, payload: dict[str, Any]) -> None:
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
request = urllib.request.Request(url, data=body, method="POST", headers={
|
||||
"Content-Type": "application/json", "X-Pi-Doctor-Token": token,
|
||||
"User-Agent": "CodexPi-PiDoctor/1",
|
||||
})
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
if response.status >= 300:
|
||||
raise RuntimeError(f"Réponse HTTP {response.status}")
|
||||
|
||||
|
||||
def deliver(base_url: str, token: str, snapshot: dict[str, Any]) -> None:
|
||||
base = base_url.rstrip("/")
|
||||
_post(f"{base}/api/pi-doctor/snapshots/import", token, {"snapshot": snapshot})
|
||||
incidents = detect_incidents(snapshot)
|
||||
if incidents:
|
||||
_post(f"{base}/api/pi-doctor/incidents/import", token, {"incidents": incidents})
|
||||
|
||||
|
||||
def enqueue(spool: Path, snapshot: dict[str, Any]) -> Path:
|
||||
spool.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
target = spool / f"{snapshot['system']['timestamp'].replace(':', '-')}-{uuid.uuid4().hex}.json"
|
||||
target.write_text(json.dumps(snapshot, ensure_ascii=False), encoding="utf-8")
|
||||
target.chmod(0o600)
|
||||
return target
|
||||
|
||||
|
||||
def flush(spool: Path, base_url: str, token: str) -> int:
|
||||
delivered = 0
|
||||
for path in sorted(spool.glob("*.json"))[:100]:
|
||||
snapshot = json.loads(path.read_text(encoding="utf-8"))
|
||||
deliver(base_url, token, snapshot)
|
||||
path.unlink()
|
||||
delivered += 1
|
||||
return delivered
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Collecteur hôte en lecture seule pour Pi Doctor")
|
||||
parser.add_argument("--url", default=os.getenv("CODEXPI_URL", "https://localhost"))
|
||||
parser.add_argument("--token-file", type=Path, default=Path(os.getenv("PI_DOCTOR_TOKEN_FILE", "/var/lib/codexpi/pi-doctor/ingest.token")))
|
||||
parser.add_argument("--spool", type=Path, default=Path(os.getenv("PI_DOCTOR_SPOOL", "/var/lib/codexpi/pi-doctor/spool")))
|
||||
parser.add_argument("--jellyfin-url", default=os.getenv("JELLYFIN_HEALTH_URL"))
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
if stat.S_IMODE(args.token_file.stat().st_mode) & 0o077:
|
||||
raise ValueError("permissions du jeton trop ouvertes")
|
||||
token = args.token_file.read_text(encoding="utf-8").strip()
|
||||
if not token:
|
||||
raise ValueError("jeton vide")
|
||||
except (OSError, ValueError) as exc:
|
||||
print(f"Pi Doctor: jeton indisponible: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
snapshot = collect(args.url, args.jellyfin_url)
|
||||
enqueue(args.spool, snapshot)
|
||||
try:
|
||||
flush(args.spool, args.url, token)
|
||||
except (OSError, RuntimeError, ValueError, urllib.error.URLError) as exc:
|
||||
print(f"Pi Doctor: collecte conservée pour réessai: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user