Files
FabledSteward/tests/core/test_dns_check.py
T
bvandeusenandClaude Opus 5.5 6788ce60f1
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 53s
CI / unit (push) Successful in 1m19s
CI / publish (push) Successful in 1m20s
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>
2026-10-07 16:14:11 -04:00

79 lines
3.0 KiB
Python

"""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