The README referenced `deploy/install-agent.sh` and a `Containerfile` that did not exist, so a fresh clone had no way to actually run this anywhere. * agent: `--token-file` (and AGENT_TOKEN_FILE). The token guards hostname, mounts, disk and GPU models and process names, and argv is world-readable through `ps`, so a file has to win over the flag. Missing or empty token files are fatal — falling back to "no token" would expose the host. * deploy/install-agent.sh: creates a system user, generates the token once (re-runs reuse it, --rotate-token for a new one), stores it 0600 root-owned, and runs the agent under ProtectSystem=strict with no capabilities; --status and --uninstall included. * Containerfile: two-stage, uv.lock resolved in the builder and slim Python in the runtime. Verified with podman — the image serves /api/health as an unprivileged user and writes its history under /var/lib/dashboards. UV_PROJECT_ENVIRONMENT and --no-editable are what make the venv survive being copied into the runtime stage. * .containerignore keeps .git, .venv, data/ and .env out of the build context. 258 tests, 94% coverage; ruff and pyright clean. LLM-Generated: true
319 lines
14 KiB
Python
319 lines
14 KiB
Python
"""The host agent: stdlib-only, read-only by construction, and wrong on no box.
|
|
|
|
Three things are asserted here:
|
|
* parsing of /proc and /sys files exactly as Linux writes them (fake roots, so the
|
|
weird cases are testable on any machine);
|
|
* the collector produces a complete, non-crashing snapshot against the *real* /proc of
|
|
whatever runs the tests, because that is the code that ships;
|
|
* the HTTP surface answers GETs, needs a token when it is exposed, and has no write path.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import re
|
|
import sys
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from http.server import ThreadingHTTPServer
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
AGENT_PATH = ROOT / "agent" / "host_agent.py"
|
|
|
|
spec = importlib.util.spec_from_file_location("host_agent", AGENT_PATH)
|
|
assert spec and spec.loader
|
|
host_agent = importlib.util.module_from_spec(spec)
|
|
sys.modules["host_agent"] = host_agent
|
|
spec.loader.exec_module(host_agent)
|
|
|
|
linux_only = pytest.mark.skipif(not sys.platform.startswith("linux"),
|
|
reason="reads /proc and /sys")
|
|
|
|
|
|
# ---- source-level helpers ------------------------------------------------- #
|
|
|
|
@pytest.mark.parametrize("lspci,expected", [
|
|
("Advanced Micro Devices, Inc. [AMD/ATI] Navi 48 [Radeon AI PRO R9700]",
|
|
"Radeon AI PRO R9700"),
|
|
("Advanced Micro Devices, Inc. [AMD/ATI] Strix Halo [Radeon Graphics / Radeon 8050S "
|
|
"Graphics / Radeon 8060S Graphics]", "Radeon 8060S Graphics"),
|
|
("Advanced Micro Devices, Inc. [AMD/ATI] Phoenix1", "Phoenix1"),
|
|
("00:02.0 VGA compatible controller: Intel Corporation UHD Graphics 620",
|
|
"UHD Graphics 620"),
|
|
("NVIDIA GB200", "NVIDIA GB200"),
|
|
("Vendor 1af1 Device 0001", "Vendor 1af1 Device 0001"),
|
|
(None, None),
|
|
("", None),
|
|
])
|
|
def test_gpu_model_is_shortened_for_legends_without_losing_identity(lspci, expected) -> None:
|
|
"""`ATI` on a legend is worse than no name: it is true of every AMD GPU on the box."""
|
|
assert host_agent.short_gpu_model(lspci) == expected
|
|
|
|
|
|
def test_gpu_model_is_capped_so_a_legend_cannot_blow_up() -> None:
|
|
long_name = "X" * 200
|
|
assert len(host_agent.short_gpu_model(long_name)) <= 41 # 40 + ellipsis
|
|
|
|
|
|
# ---- /proc and /sys parsing (fake roots) ---------------------------------- #
|
|
|
|
def write_file(root: Path, relative: str, text: str) -> None:
|
|
path = root / relative
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(text)
|
|
|
|
|
|
def test_read_number_takes_the_first_field(tmp_path: Path) -> None:
|
|
"""/proc/uptime is `210036.18 9613750.86`; float() of the whole line is None."""
|
|
write_file(tmp_path, "proc/uptime", "210036.18 9613750.86\n")
|
|
collector = host_agent.Collector(str(tmp_path))
|
|
assert collector.read_number("proc/uptime") == 210036.18
|
|
|
|
|
|
def test_read_number_handles_missing_and_junk_files(tmp_path: Path) -> None:
|
|
collector = host_agent.Collector(str(tmp_path))
|
|
assert collector.read_number("proc/nope") is None
|
|
write_file(tmp_path, "proc/junk", "\n")
|
|
assert collector.read_number("proc/junk") is None
|
|
|
|
|
|
def test_core_counts_dedupe_socket_and_core(tmp_path: Path) -> None:
|
|
"""One /proc/cpuinfo block per *thread*: counting 'core id' lines said '1 core'."""
|
|
blocks = []
|
|
for thread in range(8): # 2 sockets x 2 cores x 2 threads
|
|
socket, core = thread % 2, (thread // 2) % 2
|
|
blocks.append(f"processor\t: {thread}\nphysical id\t: {socket}\ncore id\t\t: {core}\n")
|
|
write_file(tmp_path, "proc/cpuinfo", "\n".join(blocks))
|
|
collector = host_agent.Collector(str(tmp_path))
|
|
assert collector.core_counts() == (8, 4)
|
|
|
|
|
|
def test_core_counts_falls_back_to_smt_width_on_sysfs(tmp_path: Path) -> None:
|
|
"""Some kernels/vms give no physical id/core id; cpu0's sibling list still says SMT=2."""
|
|
write_file(tmp_path, "proc/cpuinfo", "processor\t: 0\nprocessor\t: 1\n")
|
|
write_file(tmp_path, "sys/devices/system/cpu/cpu0/topology/thread_siblings_list", "0,1\n")
|
|
collector = host_agent.Collector(str(tmp_path))
|
|
logical, physical = collector.core_counts()
|
|
assert (logical, physical) == (2, 1)
|
|
|
|
|
|
def test_core_counts_survive_a_kernel_with_no_hints(tmp_path: Path) -> None:
|
|
collector = host_agent.Collector(str(tmp_path))
|
|
assert collector.core_counts()[1] is None, "unknown must be None, never 1"
|
|
|
|
|
|
def test_cpu_modes_total_excludes_guest_ticks(tmp_path: Path) -> None:
|
|
"""/proc/stat's idle/iowait are already inside 'total'; guest is inside user on Linux."""
|
|
write_file(tmp_path, "proc/stat",
|
|
"cpu 100 10 20 500 5 3 2 1 0 0\nintr 1\nctxt 2\nprocesses 3\n")
|
|
collector = host_agent.Collector(str(tmp_path))
|
|
sample = collector.cpu()["modes"]
|
|
assert sample["user"] == 100 and sample["idle"] == 500
|
|
assert sample["total"] == pytest.approx(100 + 10 + 20 + 500 + 5 + 3 + 2 + 1)
|
|
|
|
|
|
# ---- the real collector --------------------------------------------------- #
|
|
|
|
@linux_only
|
|
def test_snapshot_against_the_live_system_is_complete() -> None:
|
|
collector = host_agent.Collector("/")
|
|
snap = collector.snapshot()
|
|
for key in ("schema", "ts", "host", "cpu", "memory", "filesystems", "disk_io",
|
|
"network", "gpus", "temperatures", "processes", "errors"):
|
|
assert key in snap, f"{key} missing from a real snapshot"
|
|
assert snap["host"]["hostname"]
|
|
assert snap["cpu"]["modes"]["total"] > 0
|
|
assert len(snap["cpu"]["cores"]) >= 1
|
|
assert snap["memory"]["total_bytes"] > 0
|
|
assert snap["filesystems"], "the box it runs on has at least one mounted filesystem"
|
|
assert snap["errors"] == [], f"degraded sections must be empty on a healthy box: {snap['errors']}"
|
|
|
|
|
|
def test_captured_fixture_has_the_shape_the_dashboard_depends_on(agent_snapshot) -> None:
|
|
"""Ground truth from driveripper: if the agent's schema drifts, this fails first."""
|
|
assert agent_snapshot["schema"] == host_agent.SCHEMA_VERSION
|
|
host = agent_snapshot["host"]
|
|
assert host["hostname"] == "driveripper"
|
|
assert (host["cpu_cores_physical"], host["cpu_cores_logical"]) == (24, 48)
|
|
assert host["uptime_seconds"] > 0
|
|
gpu = agent_snapshot["gpus"][0]
|
|
assert gpu["model"] == "Radeon AI PRO R9700"
|
|
assert "model_full" in gpu and "Navi 48" in gpu["model_full"]
|
|
assert gpu["vram_total_bytes"] > 0
|
|
# the dashboard renders n/a for anything a driver omits — it must never invent a 0
|
|
assert gpu["busy_pct"] is None or 0.0 <= gpu["busy_pct"] <= 100.0
|
|
|
|
|
|
@linux_only
|
|
def test_uptime_and_boot_ts_are_reported() -> None:
|
|
"""Both are needed: uptime for the table, boot_ts to detect a counter-reset reboot."""
|
|
info = host_agent.Collector("/").host_info()
|
|
assert info["uptime_seconds"] and info["uptime_seconds"] > 0
|
|
assert info["boot_ts"] and info["boot_ts"] <= time.time()
|
|
assert abs((time.time() - info["boot_ts"]) - info["uptime_seconds"]) < 60
|
|
assert info["cpu_cores_logical"] and info["cpu_cores_physical"]
|
|
|
|
|
|
@linux_only
|
|
def test_snapshot_is_repeatable_and_does_not_deadlock() -> None:
|
|
"""snapshot() reads processes() internally; a non-reentrant lock used to hang it."""
|
|
collector = host_agent.Collector("/")
|
|
first = collector.snapshot()
|
|
second = collector.snapshot(top=0)
|
|
assert first["processes"]["total"] > 0
|
|
assert second["processes"]["top_cpu"] == [], "top=0 must skip the expensive scan"
|
|
|
|
|
|
@linux_only
|
|
def test_disk_io_comes_from_sysfs_not_proc_diskstats() -> None:
|
|
"""/proc/diskstats field order has moved between kernels; /sys/block/<dev>/stat has not."""
|
|
devices = host_agent.Collector("/").disk_io()["devices"]
|
|
assert devices
|
|
for device in devices:
|
|
assert device["reads_total"] is None or device["reads_total"] >= 0
|
|
assert device["size_bytes"] is None or device["size_bytes"] > 0
|
|
|
|
|
|
# ---- HTTP surface --------------------------------------------------------- #
|
|
|
|
@pytest.fixture
|
|
def agent_server():
|
|
"""Run the real handler on an ephemeral loopback port; yield (get, port)."""
|
|
servers: list[ThreadingHTTPServer] = []
|
|
|
|
def start(token: str | None = None) -> tuple[str, int]:
|
|
host_agent.Handler.collector = host_agent.Collector("/")
|
|
host_agent.Handler.token = token
|
|
httpd = ThreadingHTTPServer(("127.0.0.1", 0), host_agent.Handler)
|
|
servers.append(httpd)
|
|
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
|
return f"http://127.0.0.1:{httpd.server_address[1]}", httpd.server_address[1]
|
|
|
|
yield start
|
|
for httpd in servers:
|
|
httpd.shutdown()
|
|
httpd.server_close()
|
|
|
|
|
|
def _get(url: str, headers: dict[str, str] | None = None) -> tuple[int, dict]:
|
|
request = urllib.request.Request(url, headers=headers or {})
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=5) as response:
|
|
return response.status, json.loads(response.read())
|
|
except urllib.error.HTTPError as error:
|
|
body = error.read()
|
|
try:
|
|
return error.code, json.loads(body)
|
|
except json.JSONDecodeError: # pragma: no cover - handler always sends JSON
|
|
return error.code, {"raw": body.decode(errors="replace")}
|
|
|
|
|
|
def test_health_info_schema_snapshot_all_answer(agent_server) -> None:
|
|
base, _port = agent_server()
|
|
for path in ("/health", "/info", "/schema", "/snapshot"):
|
|
status, body = _get(base + path)
|
|
assert status == 200, path
|
|
assert body["ok"] is True, path
|
|
|
|
|
|
def test_snapshot_query_param_controls_the_process_scan(agent_server) -> None:
|
|
base, _port = agent_server()
|
|
_status, body = _get(base + "/snapshot?top=0")
|
|
assert body["processes"]["top_cpu"] == []
|
|
_status, full = _get(base + "/snapshot?top=3")
|
|
assert len(full["processes"]["top_cpu"]) <= 3
|
|
|
|
|
|
def test_unknown_path_is_404_not_a_snapshot(agent_server) -> None:
|
|
base, _port = agent_server()
|
|
status, body = _get(base + "/etc/shadow")
|
|
assert status == 404
|
|
assert body["ok"] is False
|
|
|
|
|
|
def test_token_is_required_when_configured_but_health_stays_open(agent_server) -> None:
|
|
base, _port = agent_server(token="s3cret")
|
|
assert _get(base + "/snapshot")[0] == 401
|
|
assert _get(base + "/snapshot", {"Authorization": "Bearer wrong"})[0] == 401
|
|
assert _get(base + "/snapshot", {"Authorization": "Bearer s3cret"})[0] == 200
|
|
# /health stays open so a monitoring probe needs no secret
|
|
assert _get(base + "/health")[0] == 200
|
|
|
|
|
|
def test_agent_has_no_write_path(agent_server) -> None:
|
|
"""No do_POST/do_PUT exists: read-only is structural, not a policy toggle."""
|
|
base, _port = agent_server()
|
|
request = urllib.request.Request(base + "/snapshot", data=b"{}", method="POST")
|
|
with pytest.raises(urllib.error.HTTPError) as excinfo:
|
|
urllib.request.urlopen(request, timeout=5)
|
|
assert excinfo.value.code in (405, 501)
|
|
|
|
|
|
def test_refuses_to_bind_off_loopback_without_a_token() -> None:
|
|
"""0.0.0.0 with no token would hand host inventory to the whole LAN."""
|
|
assert host_agent.main(["--host", "0.0.0.0", "--port", "0"]) == 2
|
|
|
|
|
|
def test_once_mode_prints_json_and_exits(capsys) -> None:
|
|
assert host_agent.main(["--once"]) == 0
|
|
printed = capsys.readouterr().out
|
|
assert json.loads(printed)["ok"] is True
|
|
|
|
|
|
def test_agent_is_dependency_free() -> None:
|
|
"""The agent must run on a stock distro python: stdlib imports, and few of them.
|
|
|
|
Two checks, because they fail for different reasons: anything outside the stdlib means a
|
|
`pip install` on every monitored host (deployment pain), while a growing footprint means
|
|
the agent can break on an older interpreter than the dashboard's own Python.
|
|
"""
|
|
text = AGENT_PATH.read_text()
|
|
imports = re.findall(r"^(?:import|from)\s+([a-zA-Z0-9_.]+)", text, re.M)
|
|
# indented (function-local) imports count too — subprocess is loaded lazily on purpose
|
|
imports += re.findall(r"^\s+(?:import|from)\s+([a-zA-Z0-9_.]+)", text, re.M)
|
|
top = {name.split(".")[0] for name in imports}
|
|
third_party = top - set(sys.stdlib_module_names) - {"host_agent"}
|
|
assert not third_party, f"agent grew a non-stdlib import: {third_party}"
|
|
assert top <= {"__future__", "argparse", "collections", "dataclasses", "http", "json",
|
|
"os", "pathlib", "re", "socket", "subprocess", "threading", "time",
|
|
"typing"}, f"agent import footprint grew: {sorted(top)}"
|
|
|
|
|
|
# ---- how the token is supplied ------------------------------------------- #
|
|
|
|
def test_token_file_wins_over_the_command_line(tmp_path) -> None:
|
|
path = tmp_path / "token"
|
|
path.write_text("from-file\n", encoding="utf-8")
|
|
token, error = host_agent.resolve_token("from-flag", str(path))
|
|
assert error is None
|
|
assert token == "from-file", "argv is world-readable in ps; the file has to win"
|
|
|
|
|
|
def test_a_missing_token_file_is_fatal_and_says_which_one(tmp_path) -> None:
|
|
missing = tmp_path / "nope"
|
|
token, error = host_agent.resolve_token("", str(missing))
|
|
assert token is None
|
|
assert error and str(missing) in error, "a silent fallback to no-token would expose the host"
|
|
|
|
|
|
def test_an_empty_token_file_is_fatal(tmp_path) -> None:
|
|
path = tmp_path / "token"
|
|
path.write_text(" \n", encoding="utf-8")
|
|
token, error = host_agent.resolve_token("ignored", str(path))
|
|
assert token is None and error is not None
|
|
|
|
|
|
def test_no_token_at_all_is_allowed_on_loopback(tmp_path) -> None:
|
|
token, error = host_agent.resolve_token("", "")
|
|
assert (token, error) == (None, None)
|
|
|
|
|
|
def test_main_refuses_to_bind_off_loopback_without_a_token(tmp_path) -> None:
|
|
code = host_agent.main(["--host", "0.0.0.0", "--token-file", str(tmp_path / "missing")])
|
|
assert code == 2, "the agent must not start exposed and unauthenticated"
|