"""Collector: host metrics — memory (/proc/meminfo), disk (shutil.disk_usage). stdlib-only, the same primitives ``disk_watchdog`` uses (D1). Every reader is never-raise: a missing path / unreadable proc-file degrades to ``None`` (one signal skipped), never a tick crash (D8). CPU "hung agent" liveness is computed from the ``/metrics`` envelope (cpu_ticks), not here. """ from __future__ import annotations import logging import shutil logger = logging.getLogger("watchdog.collectors.host") def read_mem_used_pct(meminfo_path: str = "/proc/meminfo") -> float | None: """Host memory used-% from ``/proc/meminfo`` (``MemTotal`` / ``MemAvailable``). ``used_pct = (1 - MemAvailable/MemTotal) * 100``. Returns ``None`` on a missing file / unparseable content / non-Linux (never raises). """ try: fields: dict[str, int] = {} with open(meminfo_path, "r") as f: for line in f: parts = line.split(":") if len(parts) != 2: continue key = parts[0].strip() val = parts[1].strip().split() if val: try: fields[key] = int(val[0]) # value is in kB except ValueError: continue total = fields.get("MemTotal") avail = fields.get("MemAvailable") if not total or avail is None: return None used_pct = (1.0 - (avail / total)) * 100.0 return round(used_pct, 1) except Exception as e: # noqa: BLE001 - degrade one signal, keep the tick logger.warning("watchdog: cannot read memory: %s", e) return None def read_disk_used_pct(path: str) -> float | None: """Disk used-% for one path via ``shutil.disk_usage`` (1:1 with disk_watchdog). Returns ``None`` if the path is missing / unreadable (never raises). """ try: usage = shutil.disk_usage(path) total = int(usage.total) if total <= 0: return None return round(int(usage.used) / total * 100.0, 1) except Exception as e: # noqa: BLE001 - skip this path, keep the tick logger.warning("watchdog: cannot measure disk %s: %s", path, e) return None def max_disk_used_pct(paths: list[str]) -> tuple[str, float] | None: """The fullest of ``paths`` as ``(path, used_pct)`` — the worst-case ceiling. A path that cannot be measured is skipped; ``None`` if none could be read. """ worst: tuple[str, float] | None = None for p in paths: pct = read_disk_used_pct(p) if pct is None: continue if worst is None or pct > worst[1]: worst = (p, pct) return worst