Files
bvandeusenandClaude Opus 5.5 d5601575d8
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m10s
CI / unit (push) Successful in 1m37s
CI / publish (push) Successful in 1m11s
test(alerts): seed metrics the way ingest does, through record_metrics (#5473)
#5473 "Opening an alert rule takes up to 15 s". With the rule pickers now
reading plugin_metric_latest, nine integration tests failed on rule_fields:
"host_agent has no resource 'box-…'". They seeded a resource by adding a raw
PluginMetric row, which no production path does: host-agent ingest and
record_metric both go through record_metrics, which keeps the latest-value
table. The seeds now go through record_metrics too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:28:53 -04:00

84 lines
3.7 KiB
Python

"""Integration: a host-agent ingest POST evaluates alert rules on the host's
metrics (#5060). Before, ingest wrote rows straight to plugin_metrics and no
host_agent rule could ever fire.
The agent registration is seeded with plain SQL: importing the plugin's
models again after the app loaded the plugin raises "Table already defined"
(see test_mcp_plugin_tools)."""
from __future__ import annotations
import asyncio
import hashlib
import os
import uuid
from datetime import datetime, timedelta, timezone
import pytest
pytestmark = pytest.mark.integration
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
def test_ingest_fires_a_host_rule_from_a_backlog(app):
from sqlalchemy import select, text
from steward.alerts import service as alert_svc
from steward.models.alerts import AlertEvent, AlertState, AlertStateEnum
from steward.models.hosts import Host
from steward.core.alerts import record_metrics
from steward.models.metrics import PluginMetric
from steward.models.users import User, UserRole
async def _go():
tag = uuid.uuid4().hex[:8]
hostname, token = f"agent-{tag}", f"tok-{tag}-{uuid.uuid4().hex}"
async with app.db_sessionmaker() as s:
async with s.begin():
user = User(username=f"ha-{tag}", email=f"ha-{tag}@test", password_hash="x",
role=UserRole.admin)
host = Host(name=hostname, address="10.9.9.9")
s.add_all([user, host])
await s.flush()
await s.execute(text(
"INSERT INTO host_agent_registrations "
"(id, host_id, token_hash, token_created_at, created_at, updated_at) "
"VALUES (:id, :host, :hash, now(), now(), now())"),
{"id": str(uuid.uuid4()), "host": host.id,
"hash": hashlib.sha256(token.encode()).hexdigest()})
await record_metrics(s, [PluginMetric(source_module="host_agent", resource_name=hostname,
metric_name="cpu_pct", value=5.0)])
await s.flush()
fields = await alert_svc.rule_fields(
s, name=f"cpu {tag}", source_module="host_agent", resource_name=hostname,
metric_name="cpu_pct", operator=">", threshold=90,
consecutive_failures_required=2)
rule = await alert_svc.add_rule(s, fields, created_by=user.id)
rule_id = rule.id
now = datetime.now(timezone.utc)
# Two buffered samples in one POST, newest first: evaluated oldest
# first, they make the two breaching checks the rule needs.
samples = [{"ts": (now - timedelta(seconds=s)).isoformat(), "cpu_pct": v}
for s, v in ((0, 97.0), (30, 95.0))]
client = app.test_client()
resp = await client.post("/plugins/host_agent/ingest", json={"samples": samples},
headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200, await resp.get_data(as_text=True)
async with app.db_sessionmaker() as s:
st = (await s.execute(select(AlertState).where(
AlertState.rule_id == rule_id))).scalar_one()
events = (await s.execute(select(AlertEvent).where(
AlertEvent.rule_id == rule_id))).scalars().all()
assert st.state == AlertStateEnum.firing
assert [(e.from_state, e.to_state, e.metric_value) for e in events] == [
("pending", "firing", 97.0)]
asyncio.run(_go())