#5065 "Renames carry their alerts". Alert rules, maintenance-window scopes and metric history key a thing by (source, name), so a rename used to leave the rules watching a name nothing reported under, and split the history. - core/renames.carry_rename moves AlertRule.resource_name, MaintenanceWindow.scope_resource, plugin_metrics and plugin_metrics_hourly to the new name in the caller's transaction. A host rename also carries its parts: host_agent "<host>:…" and docker "<host>/…" (HOST_SOURCES). Matching is exact (a prefix compare, not LIKE or a collation range). History left under the new name by something deleted is dropped first rather than merged in. - Web monitor edit, MCP update_monitor and web host edit call it; MCP answers with `carried` (it used to return a warning naming stranded rules). - A name another monitor of the same type, or another host, already has is refused on create and rename: two would share one history. - The rename is audited (monitor.updated / host.renamed) with what moved. Tests: an integration test renames a firing monitor through the web route and finds its rule still firing and cleared by a sample on the new name, its window and raw+hourly history moved and an orphan's history dropped; a host rename moves agent and container parts but not a longer-named host's or another source's; a guard that every host_agent resource is "<host>" or "<host>:…". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
81 lines
3.6 KiB
Python
81 lines
3.6 KiB
Python
"""Unit tests for _expand_sample_to_metrics — sample dict → PluginMetric rows.
|
|
|
|
No DB: PluginMetric objects are constructed in-memory, so we assert the
|
|
(metric_name, resource_name) → value contract directly.
|
|
"""
|
|
from datetime import datetime, timezone
|
|
|
|
from plugins.host_agent.routes import _expand_sample_to_metrics
|
|
|
|
|
|
def _index(rows):
|
|
return {(r.metric_name, r.resource_name): r.value for r in rows}
|
|
|
|
|
|
def test_expand_emits_all_new_metric_families():
|
|
sample = {
|
|
"ts": "2026-06-16T00:00:00+00:00",
|
|
"cpu_pct": 12.5,
|
|
"cpu_cores": [10.0, 15.0],
|
|
"mem": {"total_bytes": 100, "available_bytes": 40, "swap_used_bytes": 5,
|
|
"cached_bytes": 20, "buffers_bytes": 3},
|
|
"load": {"1m": 0.1, "5m": 0.2, "15m": 0.3},
|
|
"uptime_secs": 1000,
|
|
"storage": [{"mount": "/", "total_bytes": 100, "used_bytes": 50}],
|
|
"net": [{"iface": "eth0", "rx_bps": 1000.0, "tx_bps": 500.0}],
|
|
"diskio": [{"device": "sda", "read_bps": 2000.0, "write_bps": 1000.0}],
|
|
"temps": [{"label": "Package", "celsius": 45.0}, {"label": "Core0", "celsius": 50.0}],
|
|
"psi": {"mem_some_avg10": 1.5, "cpu_some_avg10": 0.0},
|
|
}
|
|
idx = _index(_expand_sample_to_metrics(sample, "alpha", datetime.now(timezone.utc)))
|
|
|
|
# per-core CPU as sub-resources
|
|
assert idx[("cpu_pct", "alpha:core0")] == 10.0
|
|
assert idx[("cpu_pct", "alpha:core1")] == 15.0
|
|
# richer memory
|
|
assert idx[("mem_cached_bytes", "alpha")] == 20
|
|
assert idx[("mem_buffers_bytes", "alpha")] == 3
|
|
# network: per-iface + host total
|
|
assert idx[("net_rx_bps", "alpha:net:eth0")] == 1000.0
|
|
assert idx[("net_rx_bps", "alpha")] == 1000.0
|
|
# disk I/O: per-device + host total
|
|
assert idx[("disk_read_bps", "alpha:diskio:sda")] == 2000.0
|
|
assert idx[("disk_write_bps", "alpha")] == 1000.0
|
|
# temps: per-sensor + host max
|
|
assert idx[("temp_c", "alpha:temp:Package")] == 45.0
|
|
assert idx[("temp_c_max", "alpha")] == 50.0
|
|
# PSI
|
|
assert idx[("psi_mem_some_avg10", "alpha")] == 1.5
|
|
|
|
|
|
def test_expand_is_backcompat_with_old_agent_sample():
|
|
"""An old agent (no new keys) must still expand cleanly to legacy metrics."""
|
|
sample = {"ts": "x", "cpu_pct": 5.0, "mem": {"total_bytes": 10, "available_bytes": 5}}
|
|
rows = _expand_sample_to_metrics(sample, "h", datetime.now(timezone.utc))
|
|
names = {r.metric_name for r in rows}
|
|
assert "cpu_pct" in names
|
|
assert not any(
|
|
n.startswith(("net_", "disk_read", "disk_write", "psi_", "temp_", "mem_cached"))
|
|
for n in names
|
|
)
|
|
|
|
|
|
def test_every_resource_is_the_host_or_a_host_colon_part():
|
|
"""A host rename carries "<host>" and "<host>:…" (steward/core/renames.py
|
|
HOST_SOURCES); a resource named any other way would stay behind."""
|
|
from steward.core.renames import HOST_SOURCES
|
|
sample = {
|
|
"ts": "x", "cpu_pct": 1.0, "cpu_cores": [1.0],
|
|
"mem": {"total_bytes": 10, "available_bytes": 5, "cached_bytes": 1, "buffers_bytes": 1},
|
|
"load": {"1m": 0.1}, "uptime_secs": 1,
|
|
"storage": [{"mount": "/", "total_bytes": 10, "used_bytes": 5}],
|
|
"net": [{"iface": "eth0", "rx_bps": 1.0, "tx_bps": 1.0}],
|
|
"diskio": [{"device": "sda", "read_bps": 1.0, "write_bps": 1.0}],
|
|
"temps": [{"label": "Package", "celsius": 40.0}],
|
|
"psi": {"cpu_some_avg10": 0.0},
|
|
}
|
|
sep = dict(HOST_SOURCES)["host_agent"]
|
|
resources = {r.resource_name for r in _expand_sample_to_metrics(sample, "web01", datetime.now(timezone.utc))}
|
|
assert len(resources) > 5
|
|
assert {r for r in resources if r != "web01" and not r.startswith("web01" + sep)} == set()
|