feat(monitors): unify ping/dns/http into one Monitor entity + custom targets
Collapse the three former check types into a single core `Monitor` entity
with one management surface (/monitors), one result table (monitor_results),
and a single scheduled task. Every type can now watch a free-standing custom
destination (optional host_id) — not just a registered Host.
- models: Monitor + MonitorResult replace PingResult/DnsResult; Host loses its
ping/dns facet columns (now Monitor rows linked by host_id).
- checks: monitors/{ping,dns,http}.py pure probes + runner.run_monitor
dispatcher; one monitor_check scheduler with a per-monitor due-filter.
- status: single monitor_status_source replaces the three sources.
- UI: /monitors blueprint (type-aware add/edit/list/widget); host hub shows a
host's linked monitors + "add monitor for this host"; nav + widget registry
+ alert metric catalog rewired. http plugin folded into core and removed.
- migration 0022 merges the http branch, data-migrates host facets +
http_monitors + all three result histories, drops the old tables/columns.
Resolves the per-host ping/dns auto-attach issue (#275): monitors are now
explicit, never auto-added to every host.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
"""Unit tests for the unified Monitor: dispatch, config plumbing, form parsing.
|
||||
|
||||
No DB / no network — the per-type probes are monkeypatched so we exercise the
|
||||
run_monitor dispatcher and the route helpers in isolation. The DB-backed status
|
||||
source + migration are covered in the integration lane.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from steward.models.monitors import Monitor, MONITOR_TYPE_VALUES
|
||||
from steward.monitors import runner as runner_mod
|
||||
from steward.monitors.routes import _build_config, _normalise_target
|
||||
|
||||
|
||||
def _monitor(mtype, target="example.com", cfg=None):
|
||||
m = Monitor(id="m1", name="t", type=mtype, target=target)
|
||||
m.set_config(cfg or {})
|
||||
return m
|
||||
|
||||
|
||||
def test_monitor_type_values():
|
||||
assert MONITOR_TYPE_VALUES == ("icmp", "tcp", "dns", "http")
|
||||
|
||||
|
||||
def test_config_json_roundtrip():
|
||||
m = _monitor("tcp", cfg={"port": 8443})
|
||||
assert m.config == {"port": 8443}
|
||||
m.set_config({"port": 22})
|
||||
assert m.config["port"] == 22
|
||||
|
||||
|
||||
def test_config_handles_bad_json():
|
||||
m = Monitor(id="x", name="t", type="tcp", target="h", config_json="not json")
|
||||
assert m.config == {}
|
||||
|
||||
|
||||
def test_run_monitor_unknown_type_is_safe():
|
||||
m = _monitor("bogus")
|
||||
res = asyncio.run(runner_mod.run_monitor(m))
|
||||
assert res["is_up"] is False
|
||||
assert "bogus" in res["error_msg"]
|
||||
|
||||
|
||||
def test_run_monitor_tcp_uses_config_port(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
async def fake_tcp(address, port):
|
||||
seen["address"], seen["port"] = address, port
|
||||
return {"is_up": True, "response_ms": 12.0}
|
||||
|
||||
monkeypatch.setattr(runner_mod, "tcp_check", fake_tcp)
|
||||
res = asyncio.run(runner_mod.run_monitor(_monitor("tcp", "db.local", {"port": 5432})))
|
||||
assert seen == {"address": "db.local", "port": 5432}
|
||||
assert res["is_up"] is True and res["response_ms"] == 12.0
|
||||
# Untouched fields stay None (full MonitorResult superset is always present).
|
||||
assert res["status_code"] is None and res["resolved_ip"] is None
|
||||
|
||||
|
||||
def test_run_monitor_http_passes_config(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
async def fake_http(**kwargs):
|
||||
seen.update(kwargs)
|
||||
return {"is_up": True, "status_code": 200, "response_ms": 5.0}
|
||||
|
||||
monkeypatch.setattr(runner_mod, "http_check", fake_http)
|
||||
cfg = {"method": "HEAD", "expected_status": 204, "content_match": "ok",
|
||||
"headers": {"X": "1"}, "timeout_seconds": 3,
|
||||
"follow_redirects": False, "verify_ssl": False}
|
||||
res = asyncio.run(runner_mod.run_monitor(_monitor("http", "https://x.test", cfg)))
|
||||
assert seen["method"] == "HEAD" and seen["expected_status"] == 204
|
||||
assert seen["headers"] == {"X": "1"} and seen["verify_ssl"] is False
|
||||
assert res["status_code"] == 200
|
||||
|
||||
|
||||
def test_run_monitor_dns_passes_expected_ip(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
async def fake_dns(address, expected_ip=None):
|
||||
seen["address"], seen["expected_ip"] = address, expected_ip
|
||||
return {"is_up": True, "resolved_ip": "192.0.2.1"}
|
||||
|
||||
monkeypatch.setattr(runner_mod, "dns_check", fake_dns)
|
||||
res = asyncio.run(runner_mod.run_monitor(_monitor("dns", "host.test", {"expected_ip": "192.0.2.1"})))
|
||||
assert seen == {"address": "host.test", "expected_ip": "192.0.2.1"}
|
||||
assert res["resolved_ip"] == "192.0.2.1"
|
||||
|
||||
|
||||
# ── route form helpers ────────────────────────────────────────────────────────
|
||||
|
||||
def test_normalise_target_adds_https_for_bare_http_host():
|
||||
assert _normalise_target("http", "example.com") == "https://example.com"
|
||||
assert _normalise_target("http", "http://x.com") == "http://x.com"
|
||||
assert _normalise_target("tcp", "example.com") == "example.com"
|
||||
|
||||
|
||||
class _Form(dict):
|
||||
"""Minimal stand-in for a Quart form (dict + 'in' membership for checkboxes)."""
|
||||
|
||||
|
||||
def test_build_config_per_type():
|
||||
assert _build_config(_Form(port="8080"), "tcp") == {"port": 8080}
|
||||
assert _build_config(_Form(), "tcp") == {"port": 80}
|
||||
assert _build_config(_Form(expected_ip="10.0.0.1"), "dns") == {"expected_ip": "10.0.0.1"}
|
||||
assert _build_config(_Form(), "dns") == {"expected_ip": None}
|
||||
assert _build_config(_Form(), "icmp") == {}
|
||||
|
||||
http = _build_config(_Form(method="get", expected_status="201", follow_redirects="on"), "http")
|
||||
assert http["method"] == "GET" and http["expected_status"] == 201
|
||||
assert http["follow_redirects"] is True # present in form → checked
|
||||
assert http["verify_ssl"] is False # absent from form → unchecked
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Integration: unified Monitor schema + status source against live Postgres.
|
||||
|
||||
Validates the 0022 unification migration applied (monitors/monitor_results
|
||||
exist, the old per-type tables are gone) and that monitor_status_source rolls
|
||||
up MonitorResult history into a StatusEntry. Requires STEWARD_DATABASE_URL.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_NEEDS_DB = pytest.mark.skipif(
|
||||
not os.environ.get("STEWARD_DATABASE_URL"),
|
||||
reason="integration test needs a live Postgres (STEWARD_DATABASE_URL)",
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@_NEEDS_DB
|
||||
def test_old_monitor_tables_dropped(app):
|
||||
from sqlalchemy import text
|
||||
|
||||
async def _go():
|
||||
async with app.db_sessionmaker() as s:
|
||||
# New tables exist…
|
||||
await s.execute(text("SELECT COUNT(*) FROM monitors"))
|
||||
await s.execute(text("SELECT COUNT(*) FROM monitor_results"))
|
||||
# …old ones are gone (regclass of a missing table is NULL).
|
||||
for tbl in ("ping_results", "dns_results", "http_monitors", "http_results"):
|
||||
missing = (await s.execute(
|
||||
text("SELECT to_regclass(:t)"), {"t": tbl})).scalar()
|
||||
assert missing is None, f"{tbl} should have been dropped"
|
||||
|
||||
asyncio.run(_go())
|
||||
|
||||
|
||||
@_NEEDS_DB
|
||||
def test_status_source_rolls_up_results(app):
|
||||
from sqlalchemy import text
|
||||
from steward.models.monitors import Monitor, MonitorResult
|
||||
from steward.core.status import monitor_status_source
|
||||
|
||||
mid = str(uuid.uuid4())
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
async def _go():
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
# Clean slate for a deterministic assertion.
|
||||
await s.execute(text("DELETE FROM monitor_results"))
|
||||
await s.execute(text("DELETE FROM monitors"))
|
||||
s.add(Monitor(id=mid, name="custom-http", type="http",
|
||||
target="https://example.test", host_id=None,
|
||||
config_json="{}", enabled=True))
|
||||
async with s.begin():
|
||||
for i, up in enumerate([True, True, False, True]):
|
||||
s.add(MonitorResult(
|
||||
id=str(uuid.uuid4()), monitor_id=mid,
|
||||
checked_at=now - timedelta(minutes=4 - i),
|
||||
is_up=up, response_ms=10.0 if up else None,
|
||||
))
|
||||
entries = await monitor_status_source(s)
|
||||
return entries
|
||||
|
||||
entries = asyncio.run(_go())
|
||||
assert len(entries) == 1
|
||||
e = entries[0]
|
||||
assert e.kind == "http" and e.name == "custom-http"
|
||||
assert e.target == "https://example.test"
|
||||
assert e.status == "up" # latest result is up
|
||||
assert len(e.heartbeat) == 4
|
||||
assert e.uptime["24h"] == 75.0 # 3 of 4 up
|
||||
Reference in New Issue
Block a user