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
+23 -1
View File
@@ -27,7 +27,7 @@ from steward.hosts import service as host_svc
from steward.models.hosts import Host
from steward.models.metrics import PluginMetric
from .downtime import recent as recent_downtime, record_events
from . import maintenance
from . import drives as drive_health, maintenance
from .models import HostAgentRegistration
from .scheduler import arrivals
# Query helpers live in a model-free module so integration tests can import them
@@ -193,6 +193,16 @@ def apply_updates(reg, sample: dict, now: datetime) -> bool:
reg.reboot_required_since, reg.reboot_packages)
def apply_drives(reg, sample: dict, now: datetime) -> bool:
"""Keep the registration's copy of the host's newest drive report (#5789),
which the host page lists: model, state and why, beside the figures."""
report = sample.get("drives")
if not isinstance(report, dict) or report == reg.drive_health:
return False
reg.drive_health, reg.drive_health_at = report, _ts_or(report.get("checked_at"), now)
return True
def _expand_sample_to_metrics(
sample: dict, host_name: str, recorded_at: datetime
) -> list[PluginMetric]:
@@ -331,6 +341,10 @@ def _expand_sample_to_metrics(
if max_temp is not None:
row("temp_c_max", host_name, max_temp)
# Each drive's health (#5789): the helper's hourly read rides every sample.
for metric, resource, value in drive_health.metric_rows(sample.get("drives"), host_name):
row(metric, resource, value)
# Pressure stall information (PSI) — host-level gauges (mem/cpu/io some|full).
for k, v in (sample.get("psi") or {}).items():
if isinstance(v, (int, float)):
@@ -527,6 +541,8 @@ async def ingest():
changed = True
if newest_sample is not None and apply_updates(reg, newest_sample, received_at):
changed = True
if newest_sample is not None and apply_drives(reg, newest_sample, received_at):
changed = True
# Mirror the reported IP into Host.address only when it's blank.
new_address = pick_host_address(host.address, host_ip)
if new_address is not None:
@@ -685,6 +701,7 @@ async def agent_source():
AGENT_FILES_DIR = Path(__file__).parent / "files"
AGENT_FILES = {
"maint.py": "text/x-python",
"install-deps.sh": "text/x-shellscript",
"steward-agent.service": "text/plain",
"steward-maint.service": "text/plain",
"steward-maint.path": "text/plain",
@@ -842,6 +859,8 @@ def _split_host_metrics(host_name: str, latest: dict) -> tuple:
disks_io[suffix[len("diskio:"):]] = metrics
elif suffix.startswith("temp:"):
temps[suffix[len("temp:"):]] = metrics.get("temp_c")
elif suffix.startswith("drive:"):
continue # listed from the drive report (#5789)
else:
mounts[suffix] = metrics
cores.sort(key=lambda c: c[0])
@@ -882,12 +901,15 @@ async def host_detail_metrics(host_id: str):
latest = await _latest_metrics_for_host(session, host.name)
hostlvl, cores, nets, disks_io, temps, mounts = _split_host_metrics(host.name, latest)
temps, unwired = split_temps(host.name, temps)
drive_report = reg.drive_health if reg else None
ls = reg.last_seen_at if reg else None
stale = ls is None or (datetime.now(timezone.utc) - ls).total_seconds() > _stale_after_seconds()
return await render_template(
"_host_metrics.html",
host=host, reg=reg, stale=stale, hostlvl=hostlvl,
cores=cores, nets=nets, disks_io=disks_io, temps=temps, unwired_temps=unwired, mounts=mounts,
drives=drive_health.drives_of(drive_report), drives_error=(drive_report or {}).get("error"),
drives_at=reg.drive_health_at if reg else None,
)