feat(monitors): a DNS monitor can name a resolver; the host page puts Ansible above Docker (#5214)
DNS monitors take an optional Resolver, a server's IP. Set, the check asks that server directly for A and AAAA over UDP and records how long it took to answer; empty keeps today's lookup through the container's resolver. "Does my local DNS still answer for public names" and "does 1.1.1.1" are now separate monitors. No new dependency. On a host's page the Ansible card moves above the Docker list, which can run to dozens of containers and hid it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
"""DNS monitors that name a resolver ask it directly (#5214)."""
|
||||
import asyncio
|
||||
import struct
|
||||
|
||||
import pytest
|
||||
|
||||
from steward.monitors import dns
|
||||
|
||||
|
||||
def _reply(qid, name, records, rcode=0):
|
||||
"""A reply to build_query(qid, name, A): the question, then `records`
|
||||
as (type, rdata), each owner name a pointer back to the question's."""
|
||||
question = dns.build_query(qid, name, 1)[12:]
|
||||
head = struct.pack("!HHHHHH", qid, 0x8180 | rcode, 1, len(records), 0, 0)
|
||||
body = b"".join(b"\xc0\x0c" + struct.pack("!HHIH", rtype, 1, 60, len(rdata)) + rdata
|
||||
for rtype, rdata in records)
|
||||
return head + question + body
|
||||
|
||||
|
||||
def test_the_query_asks_one_recursive_question():
|
||||
q = dns.build_query(0x1234, "google.com.", 28)
|
||||
assert q[:12] == struct.pack("!HHHHHH", 0x1234, 0x0100, 1, 0, 0, 0)
|
||||
assert q[12:] == b"\x06google\x03com\x00" + struct.pack("!HH", 28, 1)
|
||||
|
||||
|
||||
def test_an_answer_yields_its_addresses_past_a_cname():
|
||||
cname = b"\x03www\xc0\x0c"
|
||||
data = _reply(7, "example.com", [(5, cname), (1, bytes([93, 184, 216, 34])), (1, bytes([10, 0, 0, 1]))])
|
||||
assert dns.parse_answer(data, 1) == ["93.184.216.34", "10.0.0.1"]
|
||||
assert dns.parse_answer(data, 28) == []
|
||||
|
||||
|
||||
def test_a_server_error_is_named():
|
||||
with pytest.raises(ValueError, match="NXDOMAIN"):
|
||||
dns.parse_answer(_reply(7, "nope.example", [], rcode=3), 1)
|
||||
|
||||
|
||||
def _fake_ask(answers):
|
||||
async def ask(name, resolver, qtype):
|
||||
got = answers[qtype]
|
||||
if isinstance(got, Exception):
|
||||
raise got
|
||||
return got
|
||||
return ask
|
||||
|
||||
|
||||
def test_a_resolver_check_is_up_and_timed(monkeypatch):
|
||||
monkeypatch.setattr(dns, "_ask", _fake_ask({1: ["142.250.80.46"], 28: ["2607:f8b0::200e"]}))
|
||||
res = asyncio.run(dns.dns_check("google.com", resolver="1.1.1.1"))
|
||||
assert res["is_up"] and res["resolved_ip"] == "142.250.80.46"
|
||||
assert isinstance(res["response_ms"], float)
|
||||
|
||||
|
||||
def test_a_resolver_that_says_no_such_name_is_down_with_why(monkeypatch):
|
||||
monkeypatch.setattr(dns, "_ask", _fake_ask({1: ValueError("NXDOMAIN"), 28: ValueError("NXDOMAIN")}))
|
||||
res = asyncio.run(dns.dns_check("nope.example", resolver="192.168.0.9"))
|
||||
assert not res["is_up"] and res["error_msg"] == "192.168.0.9: NXDOMAIN"
|
||||
|
||||
|
||||
def test_a_silent_resolver_records_no_time(monkeypatch):
|
||||
silent = TimeoutError("no answer within 5s")
|
||||
monkeypatch.setattr(dns, "_ask", _fake_ask({1: silent, 28: silent}))
|
||||
res = asyncio.run(dns.dns_check("google.com", resolver="10.9.9.9"))
|
||||
assert not res["is_up"] and "response_ms" not in res
|
||||
assert res["error_msg"] == "10.9.9.9: no answer within 5s"
|
||||
|
||||
|
||||
def test_no_resolver_keeps_the_containers_lookup(monkeypatch):
|
||||
called = {}
|
||||
|
||||
async def resolve(address):
|
||||
called["address"] = address
|
||||
return {"resolved": True, "ip": "10.0.0.2", "all_ips": ["10.0.0.2"]}
|
||||
|
||||
monkeypatch.setattr(dns, "_resolve", resolve)
|
||||
monkeypatch.setattr(dns, "_ask", _fake_ask({})) # would KeyError if reached
|
||||
res = asyncio.run(dns.dns_check("nas.lan"))
|
||||
assert called == {"address": "nas.lan"} and res["is_up"] and "response_ms" not in res
|
||||
@@ -0,0 +1,19 @@
|
||||
"""The host page: what sits where (#5214)."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from quart import render_template, session
|
||||
|
||||
|
||||
async def test_ansible_comes_before_the_docker_list(app, monkeypatch):
|
||||
# The Docker list can run to dozens of containers; Ansible under it was
|
||||
# easy to miss, so it sits above.
|
||||
monkeypatch.setitem(app.config, "PLUGINS", {"docker": {"enabled": True}})
|
||||
host = SimpleNamespace(id="h1", name="box", address="10.0.0.5")
|
||||
async with app.test_request_context("/hosts/h1"):
|
||||
session["user_role"] = "operator"
|
||||
page = await render_template(
|
||||
"hosts/detail.html", host=host, monitors=[], uptime=None, alerts=[],
|
||||
monitor_types=[], linked_target=None, linkable_targets=[], ansible_sources=[])
|
||||
docker = page.index('hx-get="/plugins/docker/host/h1"')
|
||||
assert page.index(">Ansible</h3>") < docker
|
||||
assert page.index('hx-get="/plugins/host_agent/panel/h1"') < docker
|
||||
@@ -78,14 +78,16 @@ def test_run_monitor_http_passes_config(monkeypatch):
|
||||
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"}
|
||||
async def fake_dns(address, expected_ip=None, resolver=None):
|
||||
seen["address"], seen["expected_ip"], seen["resolver"] = address, expected_ip, resolver
|
||||
return {"is_up": True, "resolved_ip": "192.0.2.1", "response_ms": 12.5}
|
||||
|
||||
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 seen == {"address": "host.test", "expected_ip": "192.0.2.1", "resolver": None}
|
||||
assert res["resolved_ip"] == "192.0.2.1"
|
||||
asyncio.run(runner_mod.run_monitor(_monitor("dns", "host.test", {"resolver": "1.1.1.1"})))
|
||||
assert seen["resolver"] == "1.1.1.1"
|
||||
|
||||
|
||||
# ── config validation (shared by the form and the MCP tools) ─────────────────
|
||||
@@ -107,8 +109,9 @@ def _from_form(form, mtype):
|
||||
def test_build_config_from_the_form_per_type():
|
||||
assert _from_form(_Form(port="8080"), "tcp") == {"port": 8080}
|
||||
assert _from_form(_Form(), "tcp") == {"port": 80}
|
||||
assert _from_form(_Form(expected_ip="10.0.0.1"), "dns") == {"expected_ip": "10.0.0.1"}
|
||||
assert _from_form(_Form(), "dns") == {"expected_ip": None}
|
||||
assert _from_form(_Form(expected_ip="10.0.0.1"), "dns") == {"expected_ip": "10.0.0.1", "resolver": None}
|
||||
assert _from_form(_Form(resolver=" 1.1.1.1 "), "dns") == {"expected_ip": None, "resolver": "1.1.1.1"}
|
||||
assert _from_form(_Form(), "dns") == {"expected_ip": None, "resolver": None}
|
||||
assert _from_form(_Form(), "icmp") == {}
|
||||
|
||||
http = _from_form(_Form(method="get", expected_status="201", follow_redirects="on"), "http")
|
||||
@@ -128,6 +131,7 @@ def test_build_config_from_typed_values_defaults_to_safe_checks():
|
||||
("tcp", {"port": "http"}, "port must be a whole number"),
|
||||
("tcp", {"expected_ip": "1.2.3.4"}, "does not take expected_ip; it takes port"),
|
||||
("icmp", {"port": 22}, "takes no config"),
|
||||
("dns", {"resolver": "dns.google"}, "resolver must be a DNS server's IP address"),
|
||||
("http", {"method": "DELETE"}, "not one of GET, HEAD, POST"),
|
||||
("http", {"expected_status": 42}, "between 100 and 599"),
|
||||
("http", {"timeout_seconds": 0}, "between 1 and 60"),
|
||||
|
||||
Reference in New Issue
Block a user