| """Supervision matérielle et réseau du conteneur, sans dépendance. |
| |
| Tout est lu dans `/proc` et `/sys` — ce que le noyau expose déjà. Ajouter |
| `psutil` aurait été plus court mais impose une roue compilée à installer au |
| build, pour des informations qui tiennent en quelques fichiers texte. |
| |
| Deux précautions valables partout, et particulièrement sur un Space Hugging |
| Face : |
| |
| - **Les limites cgroup priment.** Un conteneur voit la RAM et les cœurs de la |
| machine hôte, pas son quota. `/sys/fs/cgroup/memory.max` donne la vraie |
| limite ; l'ignorer afficherait « 3 Go utilisés sur 500 Go » et masquerait la |
| saturation qui approche. |
| - **Les compteurs sont cumulatifs.** CPU et réseau ne se lisent pas en valeur |
| absolue : on garde l'échantillon précédent et on calcule le débit entre deux |
| appels. Le premier appel renvoie donc des débits nuls. |
| |
| Sur un système sans `/proc` (macOS en développement), chaque sonde renvoie |
| `None` plutôt que d'échouer : le tableau de bord affiche « indisponible ». |
| """ |
|
|
| from __future__ import annotations |
|
|
| import os |
| import time |
| from pathlib import Path |
|
|
| PROC = Path("/proc") |
| CGROUP = Path("/sys/fs/cgroup") |
|
|
| |
| _previous: dict[str, tuple[float, float]] = {} |
|
|
|
|
| def _read(path: Path) -> str | None: |
| try: |
| return path.read_text().strip() |
| except (OSError, ValueError): |
| return None |
|
|
|
|
| def _read_int(path: Path) -> int | None: |
| raw = _read(path) |
| if raw is None: |
| return None |
| try: |
| return int(raw.split()[0]) |
| except (ValueError, IndexError): |
| return None |
|
|
|
|
| def _rate(key: str, total: float, now: float) -> float | None: |
| """Débit par seconde entre cet appel et le précédent.""" |
| previous = _previous.get(key) |
| _previous[key] = (total, now) |
| if previous is None: |
| return None |
| last_total, last_time = previous |
| elapsed = now - last_time |
| if elapsed <= 0 or total < last_total: |
| return None |
| return (total - last_total) / elapsed |
|
|
|
|
| |
|
|
|
|
| def _cpu_quota() -> float | None: |
| """Nombre de cœurs réellement alloués au conteneur (cgroup v2 puis v1).""" |
| raw = _read(CGROUP / "cpu.max") |
| if raw: |
| parts = raw.split() |
| if len(parts) == 2 and parts[0] != "max": |
| try: |
| return int(parts[0]) / int(parts[1]) |
| except (ValueError, ZeroDivisionError): |
| pass |
| quota = _read_int(CGROUP / "cpu" / "cpu.cfs_quota_us") |
| period = _read_int(CGROUP / "cpu" / "cpu.cfs_period_us") |
| if quota and period and quota > 0: |
| return quota / period |
| return None |
|
|
|
|
| def _cgroup_cpu_usec() -> int | None: |
| """Temps processeur consommé par le conteneur, en microsecondes.""" |
| raw = _read(CGROUP / "cpu.stat") |
| if raw: |
| for line in raw.splitlines(): |
| if line.startswith("usage_usec"): |
| try: |
| return int(line.split()[1]) |
| except (ValueError, IndexError): |
| return None |
| |
| nanos = _read_int(CGROUP / "cpuacct" / "cpuacct.usage") |
| return nanos // 1000 if nanos is not None else None |
|
|
|
|
| def cpu() -> dict: |
| now = time.monotonic() |
| cores = os.cpu_count() |
| quota = _cpu_quota() |
| percent = None |
| scope = "conteneur" |
|
|
| |
| |
| |
| usec = _cgroup_cpu_usec() |
| if usec is not None: |
| used_rate = _rate("cpu_cgroup", usec, now) |
| if used_rate is not None: |
| budget = (quota or cores or 1) * 1_000_000 |
| percent = max(0.0, min(100.0, used_rate / budget * 100)) |
| else: |
| |
| |
| scope = "machine" |
| stat = _read(PROC / "stat") |
| if stat: |
| for line in stat.splitlines(): |
| if line.startswith("cpu "): |
| values = [int(v) for v in line.split()[1:]] |
| total = sum(values) |
| idle = values[3] + (values[4] if len(values) > 4 else 0) |
| busy_rate = _rate("cpu_busy", total - idle, now) |
| total_rate = _rate("cpu_total", total, now) |
| if busy_rate is not None and total_rate: |
| percent = max(0.0, min(100.0, busy_rate / total_rate * 100)) |
| break |
|
|
| load = None |
| loadavg = _read(PROC / "loadavg") |
| if loadavg: |
| try: |
| load = [float(v) for v in loadavg.split()[:3]] |
| except ValueError: |
| load = None |
|
|
| return { |
| "percent": round(percent, 1) if percent is not None else None, |
| "scope": scope, |
| "load": load, |
| "cores": cores, |
| "allocated_cores": round(quota, 2) if quota else None, |
| |
| |
| |
| |
| |
| "load_is_host_wide": True, |
| } |
|
|
|
|
| |
|
|
|
|
| def memory() -> dict: |
| limit = _read_int(CGROUP / "memory.max") |
| used = _read_int(CGROUP / "memory.current") |
| if limit is None: |
| limit = _read_int(CGROUP / "memory" / "memory.limit_in_bytes") |
| used = _read_int(CGROUP / "memory" / "memory.usage_in_bytes") |
|
|
| total = available = None |
| meminfo = _read(PROC / "meminfo") |
| if meminfo: |
| values = {} |
| for line in meminfo.splitlines(): |
| parts = line.split(":") |
| if len(parts) == 2: |
| try: |
| values[parts[0]] = int(parts[1].split()[0]) * 1024 |
| except (ValueError, IndexError): |
| continue |
| total = values.get("MemTotal") |
| available = values.get("MemAvailable") |
|
|
| |
| |
| if limit is not None and total is not None and limit > total * 4: |
| limit = None |
|
|
| effective_total = limit or total |
| effective_used = used if limit else (total - available if total and available else None) |
| return { |
| "total": effective_total, |
| "used": effective_used, |
| "percent": ( |
| round(effective_used / effective_total * 100, 1) |
| if effective_total and effective_used is not None |
| else None |
| ), |
| "limited_by_cgroup": limit is not None, |
| } |
|
|
|
|
| |
|
|
|
|
| |
| |
| |
| DEDICATED_VOLUME_MAX = 4 * 1024**4 |
|
|
|
|
| def _tree_size(path: str) -> int | None: |
| """Octets réellement occupés sous `path`. Le répertoire reste minuscule.""" |
| total = 0 |
| try: |
| for root, _, files in os.walk(path): |
| for name in files: |
| try: |
| total += os.stat(os.path.join(root, name)).st_size |
| except OSError: |
| continue |
| except OSError: |
| return None |
| return total |
|
|
|
|
| def disk(path: str) -> dict: |
| try: |
| stats = os.statvfs(path) |
| except OSError: |
| return {"total": None, "used": None, "free": None, "percent": None, "path": path, |
| "dedicated": False} |
| total = stats.f_blocks * stats.f_frsize |
| free = stats.f_bavail * stats.f_frsize |
| used = total - stats.f_bfree * stats.f_frsize |
|
|
| |
| |
| |
| |
| if total > DEDICATED_VOLUME_MAX: |
| return { |
| "total": None, |
| "used": _tree_size(path), |
| "free": None, |
| "percent": None, |
| "path": path, |
| "dedicated": False, |
| } |
| return { |
| "total": total, |
| "used": used, |
| "free": free, |
| "percent": round(used / total * 100, 1) if total else None, |
| "path": path, |
| "dedicated": True, |
| } |
|
|
|
|
| |
|
|
|
|
| def network() -> dict: |
| """Débits entrant et sortant, hors interface de bouclage.""" |
| raw = _read(PROC / "net" / "dev") |
| if not raw: |
| return {"rx_rate": None, "tx_rate": None, "rx_total": None, "tx_total": None} |
| now = time.monotonic() |
| rx_total = tx_total = 0 |
| for line in raw.splitlines()[2:]: |
| name, _, rest = line.partition(":") |
| if name.strip() == "lo": |
| continue |
| fields = rest.split() |
| if len(fields) >= 9: |
| try: |
| rx_total += int(fields[0]) |
| tx_total += int(fields[8]) |
| except ValueError: |
| continue |
| rx_rate = _rate("net_rx", rx_total, now) |
| tx_rate = _rate("net_tx", tx_total, now) |
| return { |
| "rx_rate": round(rx_rate) if rx_rate is not None else None, |
| "tx_rate": round(tx_rate) if tx_rate is not None else None, |
| "rx_total": rx_total, |
| "tx_total": tx_total, |
| } |
|
|
|
|
| |
|
|
|
|
| def process() -> dict: |
| rss = None |
| status = _read(PROC / "self" / "status") |
| if status: |
| for line in status.splitlines(): |
| if line.startswith("VmRSS:"): |
| try: |
| rss = int(line.split()[1]) * 1024 |
| except (ValueError, IndexError): |
| rss = None |
| break |
| threads = None |
| if status: |
| for line in status.splitlines(): |
| if line.startswith("Threads:"): |
| try: |
| threads = int(line.split()[1]) |
| except (ValueError, IndexError): |
| threads = None |
| break |
| try: |
| descriptors = len(os.listdir("/proc/self/fd")) |
| except OSError: |
| descriptors = None |
|
|
| uptime = None |
| raw = _read(PROC / "uptime") |
| if raw: |
| try: |
| uptime = float(raw.split()[0]) |
| except (ValueError, IndexError): |
| uptime = None |
|
|
| return {"rss": rss, "threads": threads, "open_files": descriptors, "host_uptime": uptime} |
|
|
|
|
| _started = time.time() |
|
|
|
|
| def snapshot(data_path: str = "/data") -> dict: |
| """Instantané complet, destiné au tableau de bord temps réel.""" |
| return { |
| "at": int(time.time() * 1000), |
| "available": PROC.is_dir(), |
| "app_uptime": round(time.time() - _started), |
| "cpu": cpu(), |
| "memory": memory(), |
| "disk": disk(data_path if os.path.isdir(data_path) else "."), |
| "network": network(), |
| "process": process(), |
| } |
|
|