feat(host_agent): each drive's health, read with smartctl, alertable (#5789)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m24s
CI / unit (push) Successful in 1m32s
CI / publish (push) Successful in 1m10s

The root helper gains one read-only action, `drives`: it finds the drives
itself (smartctl --scan-open), reads each as JSON, and writes a summary the
agent carries on every sample (agent 1.20.0). Steward judges the figures in
drives.py (failing / worn / watch / ok / no SMART / unreadable), records them
per drive and as host roll-ups for covering rules, keeps the newest report on
the registration and lists it on the host page.

install-deps.sh installs smartmontools with apt, dnf, yum, zypper, pacman or
apk, run by install.sh and every agent playbook; one it can't install is
reported, never fatal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 09:17:50 -04:00
co-authored by Claude Opus 5.5
parent fdc5c07183
commit 6c4ef368f9
20 changed files with 848 additions and 11 deletions
@@ -0,0 +1,296 @@
# tests/plugins/host_agent/test_drive_health.py
"""#5789: each drive's health, read with smartctl by the root helper, judged by
Steward, recorded as metrics, listed on the host page.
The helper's half reads smartctl's JSON, which is the same on every
distribution and for SATA, SAS and NVMe drives; the fixtures are trimmed from
real `smartctl --json -a` output (7.2/7.3).
"""
import importlib.util
import json
import os
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from types import SimpleNamespace
import pytest
from plugins.host_agent import agent as a
from plugins.host_agent import drives as dh
from tests._templates import render_plugin_template
ROOT = Path(__file__).resolve().parents[3]
FILES = ROOT / "plugins" / "host_agent" / "files"
NVME = {"smartctl": {"version": [7, 2], "exit_status": 0},
"device": {"name": "/dev/nvme0", "type": "nvme", "protocol": "NVMe"},
"model_name": "Samsung SSD 980 PRO 1TB", "user_capacity": {"bytes": 1000204886016},
"smart_status": {"passed": True, "nvme": {"value": 0}},
"temperature": {"current": 41}, "power_on_time": {"hours": 9120},
"nvme_smart_health_information_log": {"critical_warning": 0, "temperature": 41,
"available_spare": 100, "available_spare_threshold": 10,
"percentage_used": 7, "media_errors": 0}}
ATA = {"smartctl": {"version": [7, 3], "exit_status": 0},
"device": {"name": "/dev/sda", "type": "sat", "protocol": "ATA"},
"model_name": "WDC WD40EFRX-68N32N0", "user_capacity": {"bytes": 4000787030016},
"smart_support": {"available": True, "enabled": True},
"smart_status": {"passed": True}, "temperature": {"current": 34},
"ata_smart_attributes": {"table": [
{"id": 5, "name": "Reallocated_Sector_Ct", "raw": {"value": 8}},
{"id": 9, "name": "Power_On_Hours", "raw": {"value": 30211}},
{"id": 197, "name": "Current_Pending_Sector", "raw": {"value": 0}},
{"id": 198, "name": "Offline_Uncorrectable", "raw": {"value": 0}}]}}
VIRTUAL = {"smartctl": {"version": [7, 2], "exit_status": 4},
"device": {"name": "/dev/sdb", "type": "scsi", "protocol": "SCSI"},
"scsi_model_name": "QEMU HARDDISK", "smart_support": {"available": False}}
GONE = {"smartctl": {"version": [7, 2], "exit_status": 2,
"messages": [{"string": "Smartctl open device: /dev/sdc failed: No such device",
"severity": "error"}]}}
@pytest.fixture
def maint():
spec = importlib.util.spec_from_file_location("steward_maint", FILES / "maint.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
# ── the helper reads ─────────────────────────────────────────────────────────
def test_an_nvme_drive_s_figures_are_read(maint):
got = maint.drive_summary("/dev/nvme0", 0, NVME)
assert got == {"device": "nvme0", "protocol": "NVMe", "model": "Samsung SSD 980 PRO 1TB",
"capacity_bytes": 1000204886016, "passed": True, "temp_c": 41,
"power_on_hours": 9120, "critical_warning": 0, "life_used_pct": 7,
"media_errors": 0, "spare_pct": 100, "spare_threshold_pct": 10}
def test_a_sata_drive_s_bad_sectors_are_read_from_its_attributes(maint):
got = maint.drive_summary("/dev/sda", 0, ATA)
assert (got["reallocated"], got["pending"], got["offline_uncorrectable"]) == (8, 0, 0)
assert got["passed"] is True and got["temp_c"] == 34 and "life_used_pct" not in got
def test_a_drive_with_no_smart_and_one_that_can_t_be_opened_say_so(maint):
assert maint.drive_summary("/dev/sdb", 4, VIRTUAL)["available"] is False
got = maint.drive_summary("/dev/sdc", 2, GONE)
assert got == {"device": "sdc", "error": "Smartctl open device: /dev/sdc failed: No such device"}
assert maint.drive_summary("/dev/sdd", None, None)["error"] == "smartctl couldn't read it"
def test_a_failing_disk_s_figures_still_count(maint):
"""Exit bit 3 says the disk is failing; its JSON is the point, not an error."""
failing = {**ATA, "smart_status": {"passed": False}}
assert maint.drive_summary("/dev/sda", 8, failing)["passed"] is False
def test_no_smartctl_or_an_old_one_is_reported_not_parsed(maint, monkeypatch):
monkeypatch.setattr(maint, "smartctl_version", lambda: None)
assert maint.do_drives()["error"] == "smartctl not installed"
monkeypatch.setattr(maint, "smartctl_version", lambda: (6, 6))
got = maint.do_drives()
assert got["smartctl"] == "6.6" and "too old" in got["error"] and "drives" not in got
def test_the_helper_finds_the_drives_itself(maint, monkeypatch):
calls = []
def fake(args):
calls.append(args)
if args == ["--scan-open"]:
return 0, {"devices": [{"name": "/dev/sda", "type": "sat"},
{"name": "/dev/nvme0", "type": "nvme"},
{"name": "not-a-device"}]}
return 0, {"/dev/sda": ATA, "/dev/nvme0": NVME}[args[-1]]
monkeypatch.setattr(maint, "smartctl_version", lambda: (7, 2))
monkeypatch.setattr(maint, "smartctl_json", fake)
got = maint.do_drives()
assert [d["device"] for d in got["drives"]] == ["sda", "nvme0"] and got["smartctl"] == "7.2"
assert calls[1:] == [["-a", "-d", "sat", "/dev/sda"], ["-a", "-d", "nvme", "/dev/nvme0"]]
def test_a_drives_request_writes_the_report_and_no_job_result(maint, tmp_path, monkeypatch):
monkeypatch.setattr(maint, "DRIVES", str(tmp_path / "drives.json"))
monkeypatch.setattr(maint, "RESULTS", str(tmp_path / "results"))
monkeypatch.setattr(maint, "do_drives", lambda: {"checked_at": "t", "drives": []})
req = {"id": "drives-1", "action": "drives"}
path = tmp_path / "r.json"
path.write_text(json.dumps(req))
assert maint.read_request(str(path)) == req # on the helper's list
assert maint.handle(req)["state"] == "done"
assert json.loads((tmp_path / "drives.json").read_text()) == {"checked_at": "t", "drives": []}
assert oct(os.stat(tmp_path / "drives.json").st_mode & 0o777) == "0o644"
assert not (tmp_path / "results").exists() # not a maintenance job
# ── the agent asks hourly and carries the answer ─────────────────────────────
def _helper(tmp_path):
unit, requests = tmp_path / "steward-maint.path", tmp_path / "requests"
unit.write_text("[Path]\n")
requests.mkdir()
return dict(unit=str(unit), requests_dir=str(requests), drives_path=str(tmp_path / "drives.json"))
def test_the_agent_asks_at_start_then_hourly(tmp_path):
paths, state = _helper(tmp_path), {}
a.refresh_drives(state, now_mono=100.0, **paths)
assert [json.loads((tmp_path / "requests" / n).read_text())["action"]
for n in os.listdir(tmp_path / "requests")] == ["drives"]
for n in os.listdir(tmp_path / "requests"):
os.unlink(tmp_path / "requests" / n)
a.refresh_drives(state, now_mono=100.0 + 3599, **paths)
assert os.listdir(tmp_path / "requests") == []
a.refresh_drives(state, now_mono=100.0 + 3600, **paths)
assert len(os.listdir(tmp_path / "requests")) == 1
def test_the_agent_carries_the_newest_report(tmp_path):
paths, state = _helper(tmp_path), {}
a.refresh_drives(state, now_mono=0.0, **paths)
assert "drives" not in state # nothing read yet
(tmp_path / "drives.json").write_text(json.dumps({"checked_at": "t1", "drives": []}))
a.refresh_drives(state, now_mono=1.0, **paths)
assert state["drives"]["checked_at"] == "t1"
(tmp_path / "drives.json").write_text("{half") # mid-write
os.utime(tmp_path / "drives.json", (5, 5))
a.refresh_drives(state, now_mono=2.0, **paths)
assert state["drives"]["checked_at"] == "t1"
def test_without_the_helper_the_agent_says_what_drive_health_needs(tmp_path):
state = {}
a.refresh_drives(state, now_mono=0.0, unit=str(tmp_path / "none.path"),
requests_dir=str(tmp_path / "none"), drives_path=str(tmp_path / "d.json"))
assert "maintenance helper" in state["drives"]["error"]
def test_the_agent_and_the_helper_agree_on_the_report_s_place(maint):
assert a.DRIVES_PATH == maint.DRIVES and "drives" in a.MAINT_ACTIONS
# ── Steward judges ───────────────────────────────────────────────────────────
@pytest.mark.parametrize("drive, state", [
({"device": "sda", "passed": True, "reallocated": 0}, "ok"),
({"device": "sda", "passed": False}, "failing"),
({"device": "nvme0", "passed": True, "critical_warning": 4}, "failing"),
({"device": "nvme0", "passed": True, "spare_pct": 5, "spare_threshold_pct": 10}, "failing"),
({"device": "nvme0", "passed": True, "life_used_pct": 93}, "worn"),
({"device": "sda", "passed": True, "reallocated": 8}, "watch"),
({"device": "sdb", "available": False}, "unavailable"),
({"device": "sdc", "error": "No such device"}, "unreadable"),
])
def test_each_drive_is_judged(drive, state):
assert dh.judge(drive)["state"] == state
def test_what_needs_attention_is_listed_first():
report = {"drives": [{"device": "sda", "passed": True}, {"device": "sdb", "available": False},
{"device": "nvme0", "passed": False}, {"device": "sdc", "error": "x"}]}
assert [d["device"] for d in dh.drives_of(report)] == ["nvme0", "sdc", "sda", "sdb"]
assert dh.drives_of({"error": "smartctl not installed"}) == [] == dh.drives_of(None)
def test_each_read_drive_is_a_set_of_metrics_and_the_host_rolls_them_up():
report = {"drives": [{"device": "nvme0", "passed": True, "life_used_pct": 7, "media_errors": 0,
"temp_c": 41},
{"device": "sda", "passed": False, "reallocated": 8, "pending": 2},
{"device": "sdb", "available": False}]}
rows = {(m, r): v for m, r, v in dh.metric_rows(report, "box")}
assert rows == {
("drive_failing", "box:drive:sda"): 1.0, ("drive_bad_sectors", "box:drive:sda"): 10.0,
("drive_failing", "box:drive:nvme0"): 0.0, ("drive_life_used_pct", "box:drive:nvme0"): 7.0,
("drive_bad_sectors", "box:drive:nvme0"): 0.0, ("drive_temp_c", "box:drive:nvme0"): 41.0,
("drives_failing", "box"): 1.0, ("drive_life_used_max_pct", "box"): 7.0}
def test_a_host_with_no_drive_read_records_no_roll_up():
"""A VM's virtual disks, or smartctl missing: 0 failing would claim a look
that never happened."""
assert dh.metric_rows({"drives": [{"device": "vda", "available": False}]}, "box") == []
assert dh.metric_rows({"error": "smartctl not installed"}, "box") == []
def test_every_drive_metric_is_offered_to_rules_with_a_plain_label():
from plugins.host_agent import get_alert_metrics
from steward.core.formatting import METRICS, metric_unit
assert set(dh.DRIVE_METRICS) <= set(get_alert_metrics()["host_agent"])
assert all(m in METRICS for m in dh.DRIVE_METRICS)
assert metric_unit("drive_temp_c") == "°C" and metric_unit("drive_life_used_max_pct") == "%"
def test_ingest_records_the_drives_and_keeps_the_report_once():
from plugins.host_agent.routes import _expand_sample_to_metrics, apply_drives
now = datetime(2026, 10, 15, tzinfo=timezone.utc)
report = {"checked_at": "2026-10-15T00:00:00+00:00", "smartctl": "7.2",
"drives": [{"device": "sda", "passed": True}]}
rows = _expand_sample_to_metrics({"drives": report}, "box", now)
assert {(r.metric_name, r.resource_name) for r in rows} == {
("drive_failing", "box:drive:sda"), ("drives_failing", "box")}
reg = SimpleNamespace(drive_health=None, drive_health_at=None)
assert apply_drives(reg, {"drives": report}, now) is True
assert reg.drive_health == report and reg.drive_health_at == now
assert apply_drives(reg, {"drives": dict(report)}, now) is False # unchanged: no write
assert apply_drives(reg, {}, now) is False
# ── installed on any common distribution ─────────────────────────────────────
def test_the_dependency_installer_knows_the_common_package_managers():
script = (FILES / "install-deps.sh").read_text()
for manager in ("apt-get", "dnf", "yum", "zypper", "pacman", "apk"):
assert f"have {manager}" in script, manager
assert "--no-install-recommends" in script # no mail server pulled in on Debian
subprocess.run(["sh", "-n", str(FILES / "install-deps.sh")], check=True)
def test_the_dependency_installer_never_fails_the_install(tmp_path):
"""No package manager it knows (PATH holds none): it says so and exits 0."""
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
done = subprocess.run(["/bin/sh", str(FILES / "install-deps.sh")], capture_output=True, text=True,
env={"PATH": str(bin_dir)})
assert done.returncode == 0
assert "WARNING: could not install smartmontools" in done.stdout
def test_install_sh_and_every_agent_playbook_run_it():
install = (ROOT / "plugins" / "host_agent" / "templates" / "install.sh.j2").read_text()
assert '"$AGENT_DIR/install-deps.sh" || true' in install
for name in ("install", "provision", "update"):
book = (ROOT / "steward" / "ansible" / "bundled" / "host_agent" / f"{name}.yml").read_text()
assert "files/install-deps.sh" in book and "command: /usr/local/lib/steward-agent/install-deps.sh" in book, name
# ── the host page ────────────────────────────────────────────────────────────
async def test_the_host_page_lists_each_drive_failing_first(app):
report = {"drives": [{"device": "nvme0", "passed": True, "life_used_pct": 7, "temp_c": 41,
"model": "Samsung SSD 980 PRO 1TB"},
{"device": "sda", "passed": False, "reallocated": 8, "model": "WDC WD40EFRX"}]}
async with app.test_request_context("/plugins/host_agent/h1/metrics"):
page = await render_plugin_template(
app, "host_agent", "_host_metrics.html", host=SimpleNamespace(address="10.0.0.2"), stale=False,
reg=SimpleNamespace(distro=None, kernel=None, arch=None, agent_version=None,
last_seen_at=datetime.now(timezone.utc)),
hostlvl={"cpu_pct": 5.0}, cores=[], nets={}, disks_io={}, temps={}, mounts={},
drives=dh.drives_of(report), drives_error=None, drives_at=datetime.now(timezone.utc))
page = " ".join(page.split())
assert "Drive health" in page
assert page.index('badge-crit">Failing') < page.index('badge-ok">OK')
assert "its SMART self-assessment failed" in page and "8 bad sectors" in page
assert "7% life used · 0 bad sectors" not in page and "7% life used · 41°C" in page
async def test_the_host_page_says_why_drives_weren_t_read(app):
async with app.test_request_context("/plugins/host_agent/h1/metrics"):
page = await render_plugin_template(
app, "host_agent", "_host_metrics.html", host=SimpleNamespace(address="10.0.0.2"), stale=False,
reg=SimpleNamespace(distro=None, kernel=None, arch=None, agent_version=None,
last_seen_at=datetime.now(timezone.utc)),
hostlvl={"cpu_pct": 5.0}, cores=[], nets={}, disks_io={}, temps={}, mounts={},
drives=[], drives_error="smartctl not installed", drives_at=None)
assert "Not read: smartctl not installed." in page