feat(host_agent): each drive's health, read with smartctl, alertable (#5789)
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:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user