"""Tests for the Stage B (B1/B2) ``operations.host`` additions: ``host_resources`` and ``vmware_module_health``. Everything reads through the ``_read_proc`` seam -- monkeypatched per test to fixture strings, never a real ``/proc``. ``time.sleep`` is also patched to a no-op so the two-sample CPU read never actually waits ``cpu_sample_ms``. """ from __future__ import annotations from typing import Any import pytest import ci_orchestrator.operations.host as host_module from ci_orchestrator.operations.host import ( HostResources, RunnerStatus, VmwareModuleHealth, host_resources, vmware_module_health, ) # ── fixture /proc/stat samples ────────────────────────────────────────────── # Columns: user nice system idle iowait irq softirq steal guest guest_nice. # Sample 1: idle=8000+0=8000, total=1000+0+1000+8000=10000 # Sample 2: idle=8200+0=8200, total=1100+0+1100+8200=10400 # idle_delta=200, total_delta=400 -> cpu_pct = 100*(1 - 200/400) = 50.0 _STAT_1 = "cpu 1000 0 1000 8000 0 0 0 0 0 0\ncpu0 1000 0 1000 8000 0 0 0 0 0 0\n" _STAT_2 = "cpu 1100 0 1100 8200 0 0 0 0 0 0\ncpu0 1100 0 1100 8200 0 0 0 0 0 0\n" _MEMINFO = ( "MemTotal: 16384000 kB\n" "MemFree: 2000000 kB\n" "MemAvailable: 4096000 kB\n" "Buffers: 100000 kB\n" ) # total_gb = 16384000/1024**2 = 15.625; used = (16384000-4096000)/1024**2 = 11.71875 _UPTIME = "123456.78 987654.32\n" _LOADAVG = "0.50 0.75 1.00 3/456 7890\n" # Two cores' worth of /proc/cpuinfo -- the parser takes the FIRST "model name". _CPUINFO = ( "processor\t: 0\n" "vendor_id\t: GenuineIntel\n" "model name\t: Intel(R) Core(TM) i9-13900K CPU @ 3.00GHz\n" "cache size\t: 36864 KB\n" "\n" "processor\t: 1\n" "model name\t: Intel(R) Core(TM) i9-13900K CPU @ 3.00GHz\n" ) _OSRELEASE = "7.0.0-28-generic\n" _HOSTNAME = "ci-host\n" def _fake_reader(calls: dict[str, int]) -> Any: def _read(path: str) -> str: calls[path] = calls.get(path, 0) + 1 if path == "/proc/stat": return _STAT_1 if calls[path] == 1 else _STAT_2 if path == "/proc/meminfo": return _MEMINFO if path == "/proc/uptime": return _UPTIME if path == "/proc/loadavg": return _LOADAVG if path == "/proc/cpuinfo": return _CPUINFO if path == "/proc/sys/kernel/osrelease": return _OSRELEASE if path == "/proc/sys/kernel/hostname": return _HOSTNAME raise AssertionError(f"unexpected proc path: {path}") return _read def _install_fake_proc(monkeypatch: pytest.MonkeyPatch) -> dict[str, int]: calls: dict[str, int] = {} monkeypatch.setattr(host_module, "_read_proc", _fake_reader(calls)) monkeypatch.setattr(host_module.time, "sleep", lambda _seconds: None) return calls # ── host_resources: happy path, two-sample CPU math ───────────────────────── def test_host_resources_two_sample_cpu_math(monkeypatch: pytest.MonkeyPatch) -> None: calls = _install_fake_proc(monkeypatch) monkeypatch.setattr(host_module.os, "cpu_count", lambda: 8) result = host_resources(cpu_sample_ms=150) assert result.cpu_pct == pytest.approx(50.0) assert result.ncpu == 8 assert calls["/proc/stat"] == 2 # exactly two samples, per the B1 spec def test_host_resources_reads_proc_stat_twice_with_a_sleep_between( monkeypatch: pytest.MonkeyPatch, ) -> None: calls = _install_fake_proc(monkeypatch) sleeps: list[float] = [] monkeypatch.setattr(host_module.time, "sleep", lambda seconds: sleeps.append(seconds)) host_resources(cpu_sample_ms=200) assert calls["/proc/stat"] == 2 assert sleeps == [pytest.approx(0.2)] def test_host_resources_mem_used_is_total_minus_available( monkeypatch: pytest.MonkeyPatch, ) -> None: _install_fake_proc(monkeypatch) result = host_resources() assert result.mem_total_gb == pytest.approx(16384000 / 1024**2) assert result.mem_used_gb == pytest.approx((16384000 - 4096000) / 1024**2) def test_host_resources_uptime_and_loadavg(monkeypatch: pytest.MonkeyPatch) -> None: _install_fake_proc(monkeypatch) result = host_resources() assert result.uptime_sec == 123456 assert result.load1 == pytest.approx(0.50) assert result.load5 == pytest.approx(0.75) assert result.load15 == pytest.approx(1.00) # ── host_resources: static identity fields (CPU model, kernel, hostname) ───── def test_host_resources_reports_cpu_model_kernel_and_hostname( monkeypatch: pytest.MonkeyPatch, ) -> None: _install_fake_proc(monkeypatch) result = host_resources() assert result.cpu_model == "Intel(R) Core(TM) i9-13900K CPU @ 3.00GHz" assert result.kernel == "7.0.0-28-generic" assert result.hostname == "ci-host" def test_host_resources_cpu_model_is_empty_when_cpuinfo_has_no_model_name( monkeypatch: pytest.MonkeyPatch, ) -> None: """Not every architecture prints ``model name`` (e.g. arm64) -- degrade, don't crash.""" calls: dict[str, int] = {} inner = _fake_reader(calls) def _read(path: str) -> str: if path == "/proc/cpuinfo": return "processor\t: 0\nBogoMIPS\t: 50.00\n" return str(inner(path)) monkeypatch.setattr(host_module, "_read_proc", _read) monkeypatch.setattr(host_module.time, "sleep", lambda _s: None) assert host_resources().cpu_model == "" def test_host_resources_unreadable_static_fields_do_not_zero_live_metrics( monkeypatch: pytest.MonkeyPatch, ) -> None: """A missing ``/proc/cpuinfo`` must not cost us CPU%/RAM/load as well.""" calls: dict[str, int] = {} inner = _fake_reader(calls) def _read(path: str) -> str: if path in ("/proc/cpuinfo", "/proc/sys/kernel/osrelease", "/proc/sys/kernel/hostname"): raise OSError("gone") return str(inner(path)) monkeypatch.setattr(host_module, "_read_proc", _read) monkeypatch.setattr(host_module.time, "sleep", lambda _s: None) result = host_resources() assert result.cpu_pct == pytest.approx(50.0) assert result.load1 == pytest.approx(0.50) assert (result.cpu_model, result.kernel, result.hostname) == ("", "", "") # ── host_resources: degrade to zeros on OSError, never raise ──────────────── def test_host_resources_degrades_to_zeros_on_oserror(monkeypatch: pytest.MonkeyPatch) -> None: def _boom(_path: str) -> str: raise OSError("no /proc on this platform") monkeypatch.setattr(host_module, "_read_proc", _boom) monkeypatch.setattr(host_module.os, "cpu_count", lambda: 4) result = host_resources() assert result == HostResources( cpu_pct=0.0, ncpu=4, mem_total_gb=0.0, mem_used_gb=0.0, uptime_sec=0, load1=0.0, load5=0.0, load15=0.0, cpu_model="", kernel="", hostname="", ) def test_host_resources_degrades_to_zeros_when_second_stat_sample_fails( monkeypatch: pytest.MonkeyPatch, ) -> None: """The first ``/proc/stat`` read can succeed and the second still fail.""" calls = {"n": 0} def _flaky(path: str) -> str: if path.startswith("/proc/sys/kernel/") or path == "/proc/cpuinfo": return "" # static identity reads happen before the live-metric block if path != "/proc/stat": raise AssertionError("should not reach meminfo after stat blows up") calls["n"] += 1 if calls["n"] == 1: return _STAT_1 raise OSError("gone") monkeypatch.setattr(host_module, "_read_proc", _flaky) monkeypatch.setattr(host_module.time, "sleep", lambda _s: None) result = host_resources() assert result.cpu_pct == 0.0 assert result.uptime_sec == 0 # ── vmware_module_health: /proc/modules parsing ───────────────────────────── def _fake_runner_status(statuses: list[RunnerStatus]) -> Any: def _query(_names: list[str]) -> list[RunnerStatus]: return statuses return _query def test_vmware_module_health_both_modules_loaded(monkeypatch: pytest.MonkeyPatch) -> None: modules_text = "vmmon 57344 1 - Live 0x0000000000000000\nvmnet 69632 3 - Live 0x0000000000000000\n" monkeypatch.setattr(host_module, "_read_proc", lambda _path: modules_text) monkeypatch.setattr( host_module, "runner_status", _fake_runner_status([RunnerStatus(name="vmware-host.service", active=True, sub_state="running")]), ) result = vmware_module_health() assert result == VmwareModuleHealth( vmmon_loaded=True, vmnet_loaded=True, host_service_active=True, host_service_sub_state="running", ) def test_vmware_module_health_modules_absent(monkeypatch: pytest.MonkeyPatch) -> None: modules_text = "usbcore 300000 4 - Live 0x0000000000000000\n" monkeypatch.setattr(host_module, "_read_proc", lambda _path: modules_text) monkeypatch.setattr( host_module, "runner_status", _fake_runner_status([RunnerStatus(name="vmware-host.service", active=False, sub_state="dead")]), ) result = vmware_module_health() assert result.vmmon_loaded is False assert result.vmnet_loaded is False assert result.host_service_active is False assert result.host_service_sub_state == "dead" def test_vmware_module_health_degrades_on_proc_modules_oserror( monkeypatch: pytest.MonkeyPatch, ) -> None: def _boom(_path: str) -> str: raise OSError("no /proc/modules") monkeypatch.setattr(host_module, "_read_proc", _boom) monkeypatch.setattr( host_module, "runner_status", _fake_runner_status([RunnerStatus(name="vmware-host.service", active=True, sub_state="running")]), ) result = vmware_module_health() assert result.vmmon_loaded is False assert result.vmnet_loaded is False # A /proc/modules failure must not also blank the (independent) service query. assert result.host_service_active is True def test_vmware_module_health_degrades_when_runner_status_raises( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(host_module, "_read_proc", lambda _path: "vmmon 1 1 - Live 0x0\n") def _boom(_names: list[str]) -> list[RunnerStatus]: raise RuntimeError("systemctl vanished") monkeypatch.setattr(host_module, "runner_status", _boom) result = vmware_module_health() assert result.vmmon_loaded is True assert result.host_service_active is False assert result.host_service_sub_state == "unknown"