Files
wehub-resource-sync 3e779be6f3
CI / lint (push) Failing after 13m4s
CI / test (3.11, ubuntu-latest) (push) Failing after 2m4s
CI / test (3.13, ubuntu-latest) (push) Successful in 13m30s
CI / test (3.14, ubuntu-latest) (push) Successful in 17m21s
CI / test (3.12, ubuntu-latest) (push) Successful in 17m55s
CI / discover-apps-ps (push) Successful in 1m56s
CI / test (3.9, ubuntu-latest) (push) Successful in 13m17s
CI / test (3.10, ubuntu-latest) (push) Successful in 26m21s
CI / audit (push) Successful in 13m38s
Deploy site / deploy (push) Has been cancelled
CI / test (3.14, ubuntu-24.04-arm) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:37 +08:00

271 lines
10 KiB
Python

# SPDX-License-Identifier: MIT
"""Unit tests for the GUI resource snapshot (core.stats).
The pod-state query, ``podman/docker stats`` subprocess, and the guest
disk probe are all monkeypatched to canned output so the parsing logic
(cpu_pct / ram_pct / disk_pct) and the all-None degraded path are
exercised without a real backend or a running pod."""
from __future__ import annotations
import json
from dataclasses import dataclass
import pytest
from winpodx.core import stats
from winpodx.core.config import Config
GIB = 1024**3
def _running_cfg() -> Config:
cfg = Config()
cfg.pod.backend = "podman"
cfg.pod.container_name = "winpodx-windows"
cfg.pod.cpu_cores = 4
cfg.pod.ram_gb = 16
return cfg
@dataclass
class _FakeProc:
returncode: int
stdout: str
stderr: str = ""
def _patch_state(monkeypatch: pytest.MonkeyPatch, state: str) -> None:
monkeypatch.setattr(stats, "_pod_state", lambda _cfg: state)
def test_snapshot_parses_cpu_ram_disk(monkeypatch: pytest.MonkeyPatch) -> None:
# CPU is host-side; disk + guest RAM share one agent round-trip.
_patch_state(monkeypatch, "running")
monkeypatch.setattr(stats, "_host_cpu_pct", lambda _cfg: 37.5)
monkeypatch.setattr(
stats,
"_guest_resources",
lambda _cfg: ((64.0, 48.0, 75.0), (4.2, 26.25)),
)
snap = stats.pod_resource_snapshot(_running_cfg())
assert snap.pod_state == "running"
assert snap.cpu_cores == 4
assert snap.ram_gb == 16
assert snap.cpu_pct == pytest.approx(37.5)
assert snap.ram_pct == pytest.approx(26.25)
assert snap.ram_used_gb == pytest.approx(4.2)
assert snap.disk_total_gb == pytest.approx(64.0)
assert snap.disk_used_gb == pytest.approx(48.0)
assert snap.disk_pct == pytest.approx(75.0)
def test_podman_stats_parses_docker_single_object(monkeypatch: pytest.MonkeyPatch) -> None:
# The host stats fallback still parses a docker single-object response.
cfg = _running_cfg()
cfg.pod.backend = "docker"
stats_json = json.dumps({"CPUPerc": "5%", "MemUsage": "512MiB / 8GiB", "MemPerc": "6.25%"})
def fake_run(cmd, **_kwargs):
assert cmd[0] == "docker"
if "ps" in cmd:
return _FakeProc(returncode=0, stdout="winpodx-windows\n")
return _FakeProc(returncode=0, stdout=stats_json)
monkeypatch.setattr(stats.subprocess, "run", fake_run)
cpu, ram_used_gb, ram_pct = stats._podman_stats_cpu_ram(cfg)
assert cpu == pytest.approx(5.0)
assert ram_pct == pytest.approx(6.25)
assert ram_used_gb == pytest.approx(0.5, abs=0.01)
def test_snapshot_all_none_when_probes_fail(monkeypatch: pytest.MonkeyPatch) -> None:
_patch_state(monkeypatch, "running")
monkeypatch.setattr(stats, "_host_cpu_pct", lambda _cfg: None)
monkeypatch.setattr(stats, "_guest_resources", lambda _cfg: ((None, None, None), (None, None)))
snap = stats.pod_resource_snapshot(_running_cfg())
# Caps still populated from cfg; everything live degrades to None.
assert snap.pod_state == "running"
assert snap.cpu_cores == 4
assert snap.ram_gb == 16
assert snap.cpu_pct is None
assert snap.ram_used_gb is None
assert snap.ram_pct is None
assert snap.disk_total_gb is None
assert snap.disk_used_gb is None
assert snap.disk_pct is None
def test_guest_resources_uses_generous_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
# #634: the Dashboard RAM + Disk gauges read from the guest agent; a tight
# 4s budget left them stuck on "n/a" on a slow / freshly-relaunched guest.
# The probe now passes a generous timeout (off-thread, guarded — no freeze).
seen: dict[str, int] = {}
def fake_get_guest_resources(_cfg, *, timeout):
seen["timeout"] = timeout
return None
monkeypatch.setattr("winpodx.core.disk.get_guest_resources", fake_get_guest_resources)
stats._guest_resources(_running_cfg())
assert seen["timeout"] >= 12
def test_snapshot_skips_guest_probe_when_with_disk_false(monkeypatch: pytest.MonkeyPatch) -> None:
# CPU updates every tick; the guest agent round-trip is skipped off-cadence.
_patch_state(monkeypatch, "running")
monkeypatch.setattr(stats, "_host_cpu_pct", lambda _cfg: 12.0)
def fail_if_called(*_a, **_k):
raise AssertionError("guest probe must be skipped when with_disk=False")
monkeypatch.setattr(stats, "_guest_resources", fail_if_called)
snap = stats.pod_resource_snapshot(_running_cfg(), with_disk=False)
assert snap.cpu_pct == pytest.approx(12.0)
assert snap.ram_pct is None # caller caches the last guest value
assert snap.disk_pct is None
def test_snapshot_skips_live_when_not_running(monkeypatch: pytest.MonkeyPatch) -> None:
_patch_state(monkeypatch, "stopped")
def fail_if_called(*_a, **_k):
raise AssertionError("probes must not run when the pod is not running")
monkeypatch.setattr(stats, "_host_cpu_pct", fail_if_called)
monkeypatch.setattr(stats, "_guest_resources", fail_if_called)
snap = stats.pod_resource_snapshot(_running_cfg())
assert snap.pod_state == "stopped"
assert snap.cpu_cores == 4
assert snap.ram_gb == 16
assert snap.cpu_pct is None
assert snap.ram_pct is None
assert snap.disk_pct is None
def test_parse_mem_bytes_units() -> None:
assert stats._parse_mem_bytes("1GiB") == pytest.approx(GIB)
assert stats._parse_mem_bytes("512MiB") == pytest.approx(512 * 1024**2)
assert stats._parse_mem_bytes("2.5GiB") == pytest.approx(2.5 * GIB)
assert stats._parse_mem_bytes("garbage") is None
assert stats._parse_mem_bytes("") is None
def test_parse_cpu_pct_variants() -> None:
assert stats._parse_cpu_pct("12.34%") == pytest.approx(12.34)
assert stats._parse_cpu_pct("0%") == pytest.approx(0.0)
assert stats._parse_cpu_pct(7.5) == pytest.approx(7.5)
assert stats._parse_cpu_pct(None) is None
assert stats._parse_cpu_pct("") is None
assert stats._parse_cpu_pct("nope") is None
def _write_cgroup(tmp_path, *, mem_bytes: int, usage_usec: int) -> str:
(tmp_path / "memory.current").write_text(f"{mem_bytes}\n")
(tmp_path / "cpu.stat").write_text(f"usage_usec {usage_usec}\nuser_usec 0\nsystem_usec 0\n")
return str(tmp_path)
def test_cgroup_cpu_ram_reads_memory_and_cpu_delta(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
# Two reads of cumulative usage_usec, dt apart, give CPU%. RAM is one read.
cfg = _running_cfg() # cpu_cores=4, ram_gb=16
cgdir = _write_cgroup(tmp_path, mem_bytes=4 * GIB, usage_usec=1_000_000)
monkeypatch.setattr(stats, "_CPU_SAMPLE", {"mono": None, "usage_usec": None})
monkeypatch.setattr(stats, "_stats_cli", lambda _cfg: "podman")
monkeypatch.setattr(stats, "_container_pid", lambda *_a, **_k: 4321)
monkeypatch.setattr(stats, "_cgroup_dir_for_pid", lambda _pid: cgdir)
monos = iter([100.0, 102.0])
monkeypatch.setattr(stats.time, "monotonic", lambda: next(monos))
# First call: RAM resolves, CPU has no prior sample to diff -> None.
cpu0, used0, pct0 = stats._cgroup_cpu_ram(cfg)
assert cpu0 is None
assert used0 == pytest.approx(4.0)
assert pct0 == pytest.approx(25.0) # 4 / 16 GiB
# Second call: usage jumps 4.0 CPU-seconds over a 2.0s window across 4 cores
# -> 4 / 2 / 4 * 100 = 50%.
_write_cgroup(tmp_path, mem_bytes=4 * GIB, usage_usec=5_000_000)
cpu1, _used1, _pct1 = stats._cgroup_cpu_ram(cfg)
assert cpu1 == pytest.approx(50.0)
def test_host_cpu_pct_prefers_cgroup(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
# _host_cpu_pct reads the cgroup cpu.stat delta directly (instant), so it
# never needs the slow podman stats call when the cgroup CPU is available.
cfg = _running_cfg()
cgdir = _write_cgroup(tmp_path, mem_bytes=8 * GIB, usage_usec=2_000_000)
def boom_run(*_a, **_k):
raise AssertionError("podman stats must not run when the cgroup has CPU")
monkeypatch.setattr(stats.subprocess, "run", boom_run)
monkeypatch.setattr(stats, "_CPU_SAMPLE", {"mono": 100.0, "usage_usec": 0})
monkeypatch.setattr(stats, "_container_pid", lambda *_a, **_k: 4321)
monkeypatch.setattr(stats, "_cgroup_dir_for_pid", lambda _pid: cgdir)
monkeypatch.setattr(stats.time, "monotonic", lambda: 101.0)
# usage 2.0 CPU-s over 1.0s / 4 cores = 50%.
assert stats._host_cpu_pct(cfg) == pytest.approx(50.0)
def test_cgroup_dir_for_pid_v1_returns_none(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
# A cgroup v1 host has no unified "0::" line -> the reader bows out (None).
proc_cgroup = tmp_path / "cgroup"
proc_cgroup.write_text("12:devices:/foo\n11:memory:/foo\n")
real_open = open
def fake_open(path, *args, **kwargs):
if isinstance(path, str) and path.startswith("/proc/"):
return real_open(proc_cgroup, *args, **kwargs)
return real_open(path, *args, **kwargs)
monkeypatch.setattr("builtins.open", fake_open)
assert stats._cgroup_dir_for_pid(4321) is None
def test_cgroup_memory_bytes_prefers_memory_current(tmp_path) -> None:
(tmp_path / "memory.current").write_text("123456\n")
assert stats._cgroup_memory_bytes(str(tmp_path)) == 123456
def test_cgroup_memory_bytes_falls_back_to_rss_sum(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
# No memory.current (memory controller not delegated in rootless) -> sum the
# VmRSS of every PID in cgroup.procs (kernel-accounted, controller-agnostic).
import io
(tmp_path / "cgroup.procs").write_text("111\n222\n")
status = {
111: "Name:\tqemu\nVmRSS:\t 2097152 kB\n", # 2 GiB (the dockur QEMU)
222: "Name:\tconmon\nVmRSS:\t 1024 kB\n", # 1 MiB
}
real_open = open
def fake_open(path, *args, **kwargs):
if isinstance(path, str) and path.startswith("/proc/") and path.endswith("/status"):
return io.StringIO(status[int(path.split("/")[2])])
return real_open(path, *args, **kwargs)
monkeypatch.setattr("builtins.open", fake_open)
assert stats._cgroup_memory_bytes(str(tmp_path)) == (2097152 + 1024) * 1024
def test_cgroup_memory_bytes_none_when_nothing_readable(tmp_path) -> None:
# No memory.current and no cgroup.procs -> give up (None).
assert stats._cgroup_memory_bytes(str(tmp_path)) is None