feat(hosts): the hottest sensor is named with its chip, and the host-reading cutoffs are read and set over MCP (#6806)
Agent 1.27.0 sends each temperature sensor's hwmon chip. Steward keeps the chip per host and label beside the reading, so a sensor's metric key stays its label, and the strip and the heat alert name it as "NVMe · Sensor 2" rather than a bare "Sensor 2". get_thresholds (viewer) and set_thresholds (admin, audited) read and change the warn/crit cutoffs CPU, memory, disk, load and temperature are graded by, as Settings → Thresholds does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
"""get_thresholds / set_thresholds: the cutoffs host readings are graded by,
|
||||
read and changed as Settings → Thresholds does."""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from types import SimpleNamespace as NS
|
||||
|
||||
import pytest
|
||||
|
||||
from steward.mcp.tools import thresholds
|
||||
from steward.models.users import UserRole
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(monkeypatch):
|
||||
"""The settings table, the app and the audit log, in memory."""
|
||||
saved: dict = {"thresholds.temp_crit": 85}
|
||||
app = NS(config={})
|
||||
audited: list = []
|
||||
|
||||
class _Db:
|
||||
@asynccontextmanager
|
||||
async def begin(self):
|
||||
yield
|
||||
|
||||
@asynccontextmanager
|
||||
async def db_session():
|
||||
yield _Db()
|
||||
|
||||
async def get_all_settings(_db):
|
||||
return dict(saved)
|
||||
|
||||
async def set_setting(_db, key, value):
|
||||
saved[key] = value
|
||||
|
||||
async def audit_mcp(*args):
|
||||
audited.append(args)
|
||||
|
||||
monkeypatch.setattr(thresholds, "db_session", db_session)
|
||||
monkeypatch.setattr(thresholds, "get_all_settings", get_all_settings)
|
||||
monkeypatch.setattr(thresholds, "set_setting", set_setting)
|
||||
monkeypatch.setattr(thresholds, "mcp_app", lambda: app)
|
||||
monkeypatch.setattr(thresholds, "audit_mcp", audit_mcp)
|
||||
return NS(saved=saved, app=app, audited=audited)
|
||||
|
||||
|
||||
async def test_the_cutoffs_read_with_their_units_and_defaults(store):
|
||||
got = await thresholds.get_thresholds()
|
||||
assert set(got) == {"cpu", "mem", "disk", "load", "temp"}
|
||||
assert got["temp"] == {"warn": 70, "crit": 85, "unit": "°C", "measures": "hottest real thermometer",
|
||||
"default": {"warn": 70, "crit": 85}}
|
||||
|
||||
|
||||
async def test_raising_one_cutoff_keeps_the_other_and_grades_by_it_at_once(store):
|
||||
got = await thresholds.set_thresholds({"temp": {"crit": 90}})
|
||||
assert got["temp"]["crit"] == 90 and got["temp"]["warn"] == 70
|
||||
assert store.saved == {"thresholds.temp_crit": 90} # warn isn't written
|
||||
assert store.app.config["THRESHOLDS"]["temp"]["crit"] == 90
|
||||
(_action, _kind, _id, detail), = store.audited
|
||||
assert detail["changes"] == {"thresholds.temp_crit": {"from": 85, "to": 90}}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cutoffs, refusal", [
|
||||
({}, "Nothing to change"),
|
||||
({"fan": {"crit": 1}}, "No reading named fan"),
|
||||
({"temp": {"high": 90}}, "warn and/or crit"),
|
||||
({"temp": {}}, "warn and/or crit"),
|
||||
({"temp": {"crit": "hot"}}, "must be a number"),
|
||||
({"temp": {"crit": 0}}, "more than 0"),
|
||||
({"temp": {"crit": 60}}, r"warn \(70\) must be below its crit \(60\)"),
|
||||
])
|
||||
async def test_refusals_change_nothing(store, cutoffs, refusal):
|
||||
with pytest.raises(ValueError, match=refusal):
|
||||
await thresholds.set_thresholds(cutoffs)
|
||||
assert store.saved == {"thresholds.temp_crit": 85} and not store.audited
|
||||
|
||||
|
||||
def test_the_tools_are_registered_with_their_roles(app):
|
||||
mcp = app.mcp_server
|
||||
for name, role, read_only in (("get_thresholds", UserRole.viewer, True),
|
||||
("set_thresholds", UserRole.admin, False)):
|
||||
assert mcp.tool_roles[name] is role, name
|
||||
assert mcp._tool_manager.get_tool(name).annotations.read_only_hint is read_only, name
|
||||
@@ -146,3 +146,19 @@ def test_build_sample_first_call_has_no_rates_but_keeps_state(monkeypatch):
|
||||
assert s1["net"] == [] and s1["diskio"] == [] # no prior counters yet
|
||||
assert state["mono"] is not None
|
||||
assert state["net"] == {"eth0": (1000, 500)}
|
||||
|
||||
|
||||
def test_collect_temps_says_which_chip_each_sensor_is_on(tmp_path, monkeypatch):
|
||||
nvme = tmp_path / "hwmon1"
|
||||
nvme.mkdir()
|
||||
(nvme / "name").write_text("nvme\n")
|
||||
(nvme / "temp3_input").write_text("49850\n")
|
||||
(nvme / "temp3_label").write_text("Sensor 2\n")
|
||||
bare = tmp_path / "hwmon2"
|
||||
bare.mkdir()
|
||||
(bare / "name").write_text("acpitz\n")
|
||||
(bare / "temp1_input").write_text("27800\n")
|
||||
monkeypatch.setattr(agent, "HWMON_DIR", str(tmp_path))
|
||||
got = sorted(agent.collect_temps(), key=lambda t: t["label"])
|
||||
assert got == [{"label": "Sensor 2", "celsius": 49.9, "chip": "nvme"},
|
||||
{"label": "acpitz_temp1", "celsius": 27.8, "chip": "acpitz"}]
|
||||
|
||||
@@ -114,6 +114,24 @@ def test_the_heat_alert_names_the_sensor_that_set_it():
|
||||
assert hottest_sensor("box", {"AUXTIN0": 110.0}) == []
|
||||
|
||||
|
||||
def test_the_hottest_sensor_is_named_by_its_chip_once_the_agent_says_it():
|
||||
"""An NVMe drive's "Sensor 2" alone doesn't say it's the drive."""
|
||||
from plugins.host_agent.routes import hottest_sensor
|
||||
sample = {"ts": "2026-06-16T00:00:00+00:00",
|
||||
"temps": [{"label": "Sensor 2", "celsius": 49.0, "chip": "nvme"},
|
||||
{"label": "Package id 0", "celsius": 41.0, "chip": "coretemp"},
|
||||
{"label": "nct6798_temp9", "celsius": 30.0, "chip": "nct6798"},
|
||||
{"label": "fan_ic", "celsius": 35.0, "chip": "mystery9"}]}
|
||||
idx = _index(_expand_sample_to_metrics(sample, "chipbox", datetime.now(timezone.utc)))
|
||||
assert idx[("temp_c", "chipbox:temp:Sensor 2")] == 49.0 # keyed by label alone
|
||||
temps = {"Sensor 2": 49.0, "Package id 0": 41.0}
|
||||
assert hottest_sensor("chipbox", temps) == ["NVMe · Sensor 2 at 49°C"]
|
||||
assert hottest_sensor("chipbox", {"Package id 0": 41.0}) == ["CPU · Package id 0 at 41°C"]
|
||||
assert hottest_sensor("chipbox", {"nct6798_temp9": 30.0}) == ["nct6798_temp9 at 30°C"]
|
||||
assert hottest_sensor("chipbox", {"fan_ic": 35.0}) == ["mystery9 · fan_ic at 35°C"]
|
||||
assert hottest_sensor("otherbox", temps) == ["Sensor 2 at 49°C"] # an agent that never said
|
||||
|
||||
|
||||
def test_a_wired_board_input_still_counts():
|
||||
"""#5759: below 100 °C a board input is a real reading."""
|
||||
from plugins.host_agent.routes import counts_as_hottest
|
||||
|
||||
Reference in New Issue
Block a user