modified: CHANGELOG.md

new file:   netbox_dns_bridge/jobs/__init__.py
renamed:    netbox_dns_bridge/signals/notify_handler.py -> netbox_dns_bridge/jobs/notify.py
modified:   netbox_dns_bridge/management/commands/dns_transfer_endpoint/catalog_zone_manager.py
modified:   netbox_dns_bridge/signals/netbox_dns_zones.py
Restructuring

modified:   netbox_dns_bridge/tests/conftest.py
renamed:    netbox_dns_bridge/tests/test_notify_handler.py -> netbox_dns_bridge/tests/test_notify.py
modified:   netbox_dns_bridge/tests/test_signals.py
AI updated tests, not manually verified.
This commit is contained in:
sven committed 2026-08-17 10:07:46 +02:00
1 parent 9f5e40ae48
commit 3c3a1834bc
8 files changed
+295 -204

No files matched your search

+12
View File
@@ -1,4 +1,16 @@
## Unreleased ## Unreleased
- Move `notify_handler` from `signals/` to `jobs/` and rename to `notify`.
- Rename `SendClientDNSNotify` to `SendClientNotify`. It now sends a single NOTIFY
for the zone only; catalog zone entries (`catz`, `{view}.catz`) have been removed.
- Catalog zone NOTIFY is now scheduled by `increment_soa_serial` via
`schedule_catalog_zone_notify`, which enqueues `SendClientNotify` for `catz` and
`{view}.catz` — no separate job class needed.
- Extract a shared `_send_notify` helper used by both `SendClientNotify` and
`SendNSNotify` for message building and the send loop.
- Move all NOTIFY scheduling gates (`notify_clients`, `notify_ns_all_zones`, custom
field) into the scheduling functions. The signal handler calls the scheduling
functions unconditionally; each function decides whether to enqueue based on its
own settings gate.
- Replace the global `IntegerKeyValueSetting` model (single `catalog-zone-soa-serial` - Replace the global `IntegerKeyValueSetting` model (single `catalog-zone-soa-serial`
value shared by all catalog zones) with a new `CatalogZone` model that has a 1:1 value shared by all catalog zones) with a new `CatalogZone` model that has a 1:1
foreign key to `netbox_dns.models.View` and its own `soa_serial` field. Each view's foreign key to `netbox_dns.models.View` and its own `soa_serial` field. Each view's
View File
Whitespace-only changes.
@@ -17,6 +17,7 @@ from netbox_dns_bridge.models import SeenTransferClients, CatalogZone
LOGGER = get_logger(__name__) LOGGER = get_logger(__name__)
SETTINGS = settings.PLUGINS_CONFIG.get("netbox_dns_bridge", {}) SETTINGS = settings.PLUGINS_CONFIG.get("netbox_dns_bridge", {})
def _load_tsig_key(view_name: str) -> dns.tsig.Key: def _load_tsig_key(view_name: str) -> dns.tsig.Key:
tsig_keys = SETTINGS.get("tsig_keys", {}) tsig_keys = SETTINGS.get("tsig_keys", {})
if view_name not in tsig_keys: if view_name not in tsig_keys:
@@ -41,7 +42,50 @@ def _load_tsig_key(view_name: str) -> dns.tsig.Key:
return dns.tsig.Key(name=key_name, secret=secret, algorithm=algorithm) return dns.tsig.Key(name=key_name, secret=secret, algorithm=algorithm)
class SendClientDNSNotify(JobRunner): def _send_notify(
zone_name,
soa_serial,
targets,
tsig_key,
view_name,
notify_over_tcp,
port,
log_prefix="NOTIFY",
):
"""Build a DNS NOTIFY message for *zone_name* and send it to every target IP."""
fqdn = dns.name.from_text(zone_name, dns.name.root).to_text()
msg = dns.message.make_query(fqdn, dns.rdatatype.SOA)
msg.flags |= dns.flags.AA
msg.set_opcode(dns.opcode.NOTIFY)
soa_rdata = dns.rdata.from_text(
dns.rdataclass.IN,
dns.rdatatype.SOA,
f"invalid. invalid. {soa_serial} 60 10 1209600 0",
)
msg.answer.append(dns.rrset.from_rdata(fqdn, 0, soa_rdata))
msg.use_tsig(keyring={tsig_key.name: tsig_key}, keyname=tsig_key.name)
for target in targets:
try:
if notify_over_tcp:
LOGGER.debug(f"Sending {log_prefix} over TCP")
dns.query.tcp(msg, target, port=port, timeout=2)
else:
LOGGER.debug(f"Sending {log_prefix} over UDP")
dns.query.udp(msg, target, port=port, timeout=2)
LOGGER.info(
f"{log_prefix} sent: {target} {view_name}/{fqdn} {soa_serial}"
)
except Exception as e:
LOGGER.error(
f"{log_prefix} failed: {target}:{port}"
f" {view_name}/{fqdn} {soa_serial}: {e}"
)
class SendClientNotify(JobRunner):
class Meta: class Meta:
name = "Send DNS NOTIFY" name = "Send DNS NOTIFY"
@@ -49,61 +93,25 @@ class SendClientDNSNotify(JobRunner):
notify_over_tcp = SETTINGS.get("notify_over_tcp", False) notify_over_tcp = SETTINGS.get("notify_over_tcp", False)
port = SETTINGS.get("notify_client_port", 53) port = SETTINGS.get("notify_client_port", 53)
view_name = kwargs["view_name"] view_name = kwargs["view_name"]
zone_name = kwargs["zone_name"]
soa_serial = kwargs["soa_serial"]
try: try:
view = View.objects.get(name=view_name) view = View.objects.get(name=view_name)
try:
catz_serial = view.catalog_zone.soa_serial
except CatalogZone.DoesNotExist as e:
LOGGER.error(f"Failed to get catalog zone serial: {e}")
return
zones = [
(kwargs["zone_name"], kwargs["soa_serial"]),
("catz", catz_serial),
(f"{view_name}.catz", catz_serial),
]
tsig_key = _load_tsig_key(view_name) tsig_key = _load_tsig_key(view_name)
cutoff_hours = SETTINGS.get("notify_client_alive_threshold_hours", 24) cutoff_hours = SETTINGS.get("notify_client_alive_threshold_hours", 24)
seen_cutoff = timezone.now() - timezone.timedelta(hours=cutoff_hours) seen_cutoff = timezone.now() - timezone.timedelta(hours=cutoff_hours)
clients = SeenTransferClients.objects.filter( clients = list(
view=view, last_seen__gte=seen_cutoff SeenTransferClients.objects.filter(
view=view, last_seen__gte=seen_cutoff
).values_list("source_ip", flat=True)
) )
for zone_name, soa_serial in zones: _send_notify(
zone_name = dns.name.from_text(zone_name, dns.name.root).to_text() zone_name, soa_serial, clients, tsig_key,
msg = dns.message.make_query(zone_name, dns.rdatatype.SOA) view_name, notify_over_tcp, port, log_prefix="NOTIFY",
msg.flags |= dns.flags.AA # = 0 )
msg.set_opcode(dns.opcode.NOTIFY)
soa_rdata = dns.rdata.from_text(
dns.rdataclass.IN,
dns.rdatatype.SOA,
f"invalid. invalid. {soa_serial} 60 10 1209600 0",
)
soa_rrset = dns.rrset.from_rdata(zone_name, 0, soa_rdata)
msg.answer.append(soa_rrset)
msg.use_tsig(keyring={tsig_key.name: tsig_key}, keyname=tsig_key.name)
for client in clients:
try:
if notify_over_tcp:
LOGGER.debug("Sending NOTIFY over TCP")
dns.query.tcp(msg, client.source_ip, port=port, timeout=2)
else:
LOGGER.debug("Sending NOTIFY over UDP")
dns.query.udp(msg, client.source_ip, port=port, timeout=2)
LOGGER.info(
f"NOTIFY sent: {client.source_ip} {view_name}/{zone_name} {soa_serial}"
)
except Exception as e:
LOGGER.error(
f"NOTIFY failed: {client.source_ip}:{port}"
f" {view_name}/{zone_name} {soa_serial}: {e}"
)
except View.DoesNotExist as e: except View.DoesNotExist as e:
LOGGER.error(f"Failed to get view for NOTIFY: {e}") LOGGER.error(f"Failed to get view for NOTIFY: {e}")
@@ -116,10 +124,10 @@ class SendClientDNSNotify(JobRunner):
class SendNSNotify(JobRunner): class SendNSNotify(JobRunner):
"""Send a NOTIFY straight to a zone's own nameservers. """Send a NOTIFY straight to a zone's own nameservers.
Unlike ``SendClientDNSNotify`` (which notifies clients that previously queried the Unlike ``SendClientNotify`` (which notifies clients that previously queried
transfer endpoint), this job resolves the addresses of the zone's configured the transfer endpoint), this job resolves the addresses of the zone's
nameservers from NetBox DNS itself and notifies each of them so they pick up configured nameservers from NetBox DNS itself and notifies each of them so
the changed zone quickly. they pick up the changed zone quickly.
""" """
class Meta: class Meta:
@@ -156,33 +164,10 @@ class SendNSNotify(JobRunner):
LOGGER.error(f"NS NOTIFY aborted for {view_name}/{zone_name}: {e}") LOGGER.error(f"NS NOTIFY aborted for {view_name}/{zone_name}: {e}")
return return
fqdn = dns.name.from_text(zone_name, dns.name.root).to_text() _send_notify(
msg = dns.message.make_query(fqdn, dns.rdatatype.SOA) zone_name, soa_serial, ns_targets, tsig_key,
msg.flags |= dns.flags.AA view_name, notify_over_tcp, port, log_prefix="NS NOTIFY",
msg.set_opcode(dns.opcode.NOTIFY)
soa_rdata = dns.rdata.from_text(
dns.rdataclass.IN,
dns.rdatatype.SOA,
f"invalid. invalid. {soa_serial} 60 10 1209600 0",
) )
msg.answer.append(dns.rrset.from_rdata(fqdn, 0, soa_rdata))
msg.use_tsig(keyring={tsig_key.name: tsig_key}, keyname=tsig_key.name)
for ip in ns_targets:
try:
if notify_over_tcp:
LOGGER.debug("Sending NS NOTIFY over TCP")
dns.query.tcp(msg, ip, port=port, timeout=2)
else:
LOGGER.debug("Sending NS NOTIFY over UDP")
dns.query.udp(msg, ip, port=port, timeout=2)
LOGGER.info(f"NS NOTIFY sent: {ip} {view_name}/{fqdn} {soa_serial}")
except Exception as e:
LOGGER.error(
f"NS NOTIFY failed: {ip} {view_name}/{fqdn} {soa_serial}: {e}"
)
def _get_soa_serial(zone: Zone): def _get_soa_serial(zone: Zone):
@@ -239,12 +224,33 @@ def _resolve_zone_nameserver_ips(zone: Zone) -> list:
return ips return ips
def _notify_ns_enabled(zone) -> bool:
"""Return True if a NOTIFY to the zone's nameservers should be sent.
Enabled either globally for every zone via ``notify_ns_all_zones`` or per
zone when the boolean custom field named by ``notify_ns_custom_field_name``
is set to True on the zone.
"""
if SETTINGS.get("notify_ns_all_zones", False):
return True
cf_name = SETTINGS.get("notify_ns_custom_field_name")
if not cf_name:
return False
custom_fields = getattr(zone, "custom_field_data", None) or {}
return custom_fields.get(cf_name) is True
def schedule_client_notify(zone: Zone): def schedule_client_notify(zone: Zone):
if not SETTINGS.get("notify_clients", False):
return
soa_serial = _get_soa_serial(zone) soa_serial = _get_soa_serial(zone)
if soa_serial is None: if soa_serial is None:
return return
SendClientDNSNotify.enqueue( SendClientNotify.enqueue(
zone_name=zone.name, zone_name=zone.name,
view_name=zone.view.name, view_name=zone.view.name,
soa_serial=soa_serial, soa_serial=soa_serial,
@@ -252,6 +258,9 @@ def schedule_client_notify(zone: Zone):
def schedule_ns_notify(zone: Zone): def schedule_ns_notify(zone: Zone):
if not _notify_ns_enabled(zone):
return
soa_serial = _get_soa_serial(zone) soa_serial = _get_soa_serial(zone)
if soa_serial is None: if soa_serial is None:
return return
@@ -262,3 +271,24 @@ def schedule_ns_notify(zone: Zone):
view_name=zone.view.name, view_name=zone.view.name,
soa_serial=soa_serial, soa_serial=soa_serial,
) )
def schedule_catalog_zone_notify(view: View):
if not SETTINGS.get("notify_clients", False):
return
try:
serial = view.catalog_zone.soa_serial
except CatalogZone.DoesNotExist:
return
SendClientNotify.enqueue(
zone_name="catz",
soa_serial=serial,
view_name=view.name,
)
SendClientNotify.enqueue(
zone_name=f"{view.name}.catz",
soa_serial=serial,
view_name=view.name,
)
@@ -5,6 +5,7 @@ import dns.rdataclass
from netbox_dns_bridge.utils import get_logger from netbox_dns_bridge.utils import get_logger
from netbox_dns.models import Zone as NBZone, View as NBView from netbox_dns.models import Zone as NBZone, View as NBView
from netbox_dns_bridge.models import CatalogZone, CatalogZoneMemberIdentifier from netbox_dns_bridge.models import CatalogZone, CatalogZoneMemberIdentifier
from netbox_dns_bridge.jobs import notify
from uuid import uuid4 from uuid import uuid4
from base64 import b32encode from base64 import b32encode
from django.db import transaction, close_old_connections, OperationalError from django.db import transaction, close_old_connections, OperationalError
@@ -38,12 +39,16 @@ def increment_soa_serial(view: NBView) -> int:
f"is now {soa_serial_obj.soa_serial}" f"is now {soa_serial_obj.soa_serial}"
) )
return soa_serial_obj.soa_serial serial = soa_serial_obj.soa_serial
except OperationalError as e: except OperationalError as e:
LOGGER.error( LOGGER.error(
f"ERROR: Failed to increment the Catalog Zone's SOA serial: {e}" f"ERROR: Failed to increment the Catalog Zone's SOA serial: {e}"
) )
close_old_connections() close_old_connections()
return None
notify.schedule_catalog_zone_notify(view)
return serial
def create_zone(name, view_name) -> dns.zone.Zone: def create_zone(name, view_name) -> dns.zone.Zone:
+6 -29
View File
@@ -1,13 +1,11 @@
from django.db.models.signals import pre_save, post_save, post_delete from django.db.models.signals import pre_save, post_save, post_delete
from django.dispatch import receiver from django.dispatch import receiver
from django.db import transaction from django.db import transaction
from django.conf import settings
from netbox.context import current_request from netbox.context import current_request
from netbox_dns.models import Zone, View from netbox_dns.models import Zone, View
from netbox_dns_bridge.management.commands.dns_transfer_endpoint import catalog_zone_manager as catzm from netbox_dns_bridge.management.commands.dns_transfer_endpoint import catalog_zone_manager as catzm
from . import notify_handler from netbox_dns_bridge.jobs import notify
SETTINGS = settings.PLUGINS_CONFIG.get("netbox_dns_bridge", {})
@receiver(pre_save, sender=Zone) @receiver(pre_save, sender=Zone)
def zone_pre_save(sender, instance, **kwargs): def zone_pre_save(sender, instance, **kwargs):
@@ -61,7 +59,8 @@ def zone_post_save(sender, instance, created, **kwargs):
and old_view_id != zone.view_id and old_view_id != zone.view_id
) )
# On commit, increment soa serial and if notify is enabled, schedule bg job. # On commit, increment soa serial and schedule notify jobs. All settings
# gating happens inside the scheduling functions.
def _on_commit(): def _on_commit():
if view_changed: if view_changed:
try: try:
@@ -71,13 +70,9 @@ def zone_post_save(sender, instance, created, **kwargs):
pass pass
catzm.increment_soa_serial(zone.view) catzm.increment_soa_serial(zone.view)
if SETTINGS.get("notify_clients", False): if soa_serial_changed:
notify_handler.schedule_client_notify(zone) notify.schedule_client_notify(zone)
notify.schedule_ns_notify(zone)
# Send a NOTIFY to the zone's own nameservers when its SOA serial changed
# and the feature is enabled — globally or per zone via a custom field.
if soa_serial_changed and _notify_ns_enabled(zone):
notify_handler.schedule_ns_notify(zone)
transaction.on_commit(_on_commit) transaction.on_commit(_on_commit)
@@ -99,21 +94,3 @@ def zone_post_delete(sender, instance, **kwargs):
catzm.increment_soa_serial(instance.view) catzm.increment_soa_serial(instance.view)
transaction.on_commit(_on_commit) transaction.on_commit(_on_commit)
def _notify_ns_enabled(zone) -> bool:
"""Return True if a NOTIFY to the zone's nameservers should be sent.
Enabled either globally for every zone via ``notify_ns_all_zones`` or per
zone when the boolean custom field named by ``notify_ns_custom_field_name``
is set to True on the zone.
"""
if SETTINGS.get("notify_ns_all_zones", False):
return True
cf_name = SETTINGS.get("notify_ns_custom_field_name")
if not cf_name:
return False
custom_fields = getattr(zone, "custom_field_data", None) or {}
return custom_fields.get(cf_name) is True
+2 -3
View File
@@ -27,11 +27,10 @@ def default_view(db):
def _settings_modules(): def _settings_modules():
from netbox_dns_bridge.management.commands.dns_transfer_endpoint import ( from netbox_dns_bridge.management.commands.dns_transfer_endpoint import (
request_handler, request_handler,
catalog_zone_manager as catzm, # noqa: F401
) )
from netbox_dns_bridge.signals import netbox_dns_zones, notify_handler from netbox_dns_bridge.jobs import notify
return [netbox_dns_zones, notify_handler, request_handler] return [notify, request_handler]
@pytest.fixture @pytest.fixture
@@ -1,5 +1,5 @@
"""Tests for notify_handler: _load_tsig_key, _get_soa_serial, _resolve_zone_nameserver_ips, """Tests for notify: _load_tsig_key, _get_soa_serial, _resolve_zone_nameserver_ips,
schedule_client_notify, schedule_ns_notify, and job classes.""" schedule_client_notify, schedule_ns_notify, schedule_catalog_zone_notify, and job classes."""
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
import dns.name import dns.name
@@ -7,7 +7,7 @@ import dns.rdatatype
import dns.tsig import dns.tsig
import pytest import pytest
import netbox_dns_bridge.signals.notify_handler as nh import netbox_dns_bridge.jobs.notify as nh
class TestLoadTsigKey: class TestLoadTsigKey:
@@ -156,9 +156,10 @@ class TestResolveZoneNameserverIps:
@pytest.mark.django_db @pytest.mark.django_db
class TestScheduleClientNotify: class TestScheduleClientNotify:
def test_enqueues_job_when_soa_exists(self, make_zone): def test_enqueues_job_when_enabled(self, make_zone, monkeypatch):
zone = make_zone(name="sched-client.test", soa_serial=77) zone = make_zone(name="sched-client.test", soa_serial=77)
with patch.object(nh.SendClientDNSNotify, "enqueue") as mock_enq: monkeypatch.setattr(nh, "SETTINGS", {"notify_clients": True})
with patch.object(nh.SendClientNotify, "enqueue") as mock_enq:
nh.schedule_client_notify(zone) nh.schedule_client_notify(zone)
mock_enq.assert_called_once_with( mock_enq.assert_called_once_with(
zone_name="sched-client.test", zone_name="sched-client.test",
@@ -166,20 +167,29 @@ class TestScheduleClientNotify:
soa_serial=77, soa_serial=77,
) )
def test_does_not_enqueue_when_no_soa(self, make_zone): def test_does_not_enqueue_when_no_soa(self, make_zone, monkeypatch):
from netbox_dns.models import Record from netbox_dns.models import Record
zone = make_zone(name="sched-client-nosoa.test") zone = make_zone(name="sched-client-nosoa.test")
Record.objects.filter(zone=zone, type="SOA").delete() Record.objects.filter(zone=zone, type="SOA").delete()
with patch.object(nh.SendClientDNSNotify, "enqueue") as mock_enq: monkeypatch.setattr(nh, "SETTINGS", {"notify_clients": True})
with patch.object(nh.SendClientNotify, "enqueue") as mock_enq:
nh.schedule_client_notify(zone)
mock_enq.assert_not_called()
def test_does_not_enqueue_when_notify_clients_disabled(self, make_zone, monkeypatch):
zone = make_zone(name="sched-client-disabled.test", soa_serial=77)
monkeypatch.setattr(nh, "SETTINGS", {"notify_clients": False})
with patch.object(nh.SendClientNotify, "enqueue") as mock_enq:
nh.schedule_client_notify(zone) nh.schedule_client_notify(zone)
mock_enq.assert_not_called() mock_enq.assert_not_called()
@pytest.mark.django_db @pytest.mark.django_db
class TestScheduleNsNotify: class TestScheduleNsNotify:
def test_enqueues_job_when_soa_exists(self, make_zone): def test_enqueues_job_when_enabled(self, make_zone, monkeypatch):
zone = make_zone(name="sched-ns.test", soa_serial=88) zone = make_zone(name="sched-ns.test", soa_serial=88)
monkeypatch.setattr(nh, "SETTINGS", {"notify_ns_all_zones": True})
with patch.object(nh.SendNSNotify, "enqueue") as mock_enq: with patch.object(nh.SendNSNotify, "enqueue") as mock_enq:
nh.schedule_ns_notify(zone) nh.schedule_ns_notify(zone)
mock_enq.assert_called_once_with( mock_enq.assert_called_once_with(
@@ -189,33 +199,75 @@ class TestScheduleNsNotify:
soa_serial=88, soa_serial=88,
) )
def test_does_not_enqueue_when_no_soa(self, make_zone): def test_does_not_enqueue_when_no_soa(self, make_zone, monkeypatch):
from netbox_dns.models import Record from netbox_dns.models import Record
zone = make_zone(name="sched-ns-nosoa.test") zone = make_zone(name="sched-ns-nosoa.test")
Record.objects.filter(zone=zone, type="SOA").delete() Record.objects.filter(zone=zone, type="SOA").delete()
monkeypatch.setattr(nh, "SETTINGS", {"notify_ns_all_zones": True})
with patch.object(nh.SendNSNotify, "enqueue") as mock_enq:
nh.schedule_ns_notify(zone)
mock_enq.assert_not_called()
def test_does_not_enqueue_when_ns_notify_disabled(self, make_zone, monkeypatch):
zone = make_zone(name="sched-ns-disabled.test", soa_serial=88)
monkeypatch.setattr(nh, "SETTINGS", {})
with patch.object(nh.SendNSNotify, "enqueue") as mock_enq: with patch.object(nh.SendNSNotify, "enqueue") as mock_enq:
nh.schedule_ns_notify(zone) nh.schedule_ns_notify(zone)
mock_enq.assert_not_called() mock_enq.assert_not_called()
@pytest.mark.django_db @pytest.mark.django_db
class TestSendClientDNSNotifyRun: class TestScheduleCatalogZoneNotify:
def test_sends_notify_to_seen_clients(self, make_zone, make_view, monkeypatch): def test_enqueues_two_jobs_when_enabled(self, make_view, monkeypatch):
import netbox_dns_bridge.signals.notify_handler as nh from netbox_dns_bridge.models import CatalogZone
from netbox_dns_bridge.models import SeenTransferClients, CatalogZone
view = make_view(name="catz-notify-view")
CatalogZone.objects.create(view=view, soa_serial=42)
monkeypatch.setattr(nh, "SETTINGS", {"notify_clients": True})
with patch.object(nh.SendClientNotify, "enqueue") as mock_enq:
nh.schedule_catalog_zone_notify(view)
assert mock_enq.call_count == 2
mock_enq.assert_any_call(
zone_name="catz", soa_serial=42, view_name="catz-notify-view"
)
mock_enq.assert_any_call(
zone_name="catz-notify-view.catz",
soa_serial=42,
view_name="catz-notify-view",
)
def test_does_not_enqueue_when_notify_clients_disabled(self, make_view, monkeypatch):
from netbox_dns_bridge.models import CatalogZone
view = make_view(name="catz-notify-disabled")
CatalogZone.objects.create(view=view, soa_serial=42)
monkeypatch.setattr(nh, "SETTINGS", {"notify_clients": False})
with patch.object(nh.SendClientNotify, "enqueue") as mock_enq:
nh.schedule_catalog_zone_notify(view)
mock_enq.assert_not_called()
def test_does_not_enqueue_when_no_catalog_zone(self, make_view, monkeypatch):
view = make_view(name="catz-notify-no-cz")
monkeypatch.setattr(nh, "SETTINGS", {"notify_clients": True})
with patch.object(nh.SendClientNotify, "enqueue") as mock_enq:
nh.schedule_catalog_zone_notify(view)
mock_enq.assert_not_called()
@pytest.mark.django_db
class TestSendClientNotifyRun:
def test_sends_single_notify_to_seen_clients(self, make_zone, make_view, monkeypatch):
import netbox_dns_bridge.jobs.notify as nh
from netbox_dns_bridge.models import SeenTransferClients
from django.utils import timezone from django.utils import timezone
view = make_view(name="notify-run-view") view = make_view(name="notify-run-view")
CatalogZone.objects.update_or_create(
view=view, defaults={"soa_serial": 42}
)
SeenTransferClients.objects.create( SeenTransferClients.objects.create(
source_ip="192.0.2.99", view=view, last_seen=timezone.now() source_ip="192.0.2.99", view=view, last_seen=timezone.now()
) )
monkeypatch.setattr(nh, "SETTINGS", { monkeypatch.setattr(nh, "SETTINGS", {
"notify_clients": True,
"tsig_keys": { "tsig_keys": {
"notify-run-view": { "notify-run-view": {
"keyname": "nk.", "keyname": "nk.",
@@ -229,21 +281,23 @@ class TestSendClientDNSNotifyRun:
}) })
job = MagicMock() job = MagicMock()
job.run.__wrapped__ = nh.SendClientDNSNotify.run
with patch("dns.query.udp") as mock_udp: with patch("dns.query.udp") as mock_udp:
nh.SendClientDNSNotify.run(job, nh.SendClientNotify.run(job,
zone_name="z.test", soa_serial=10, view_name="notify-run-view") zone_name="z.test", soa_serial=10, view_name="notify-run-view")
assert mock_udp.call_count >= 3 # zone + catz + view.catz assert mock_udp.call_count == 1
def test_handles_missing_serial_setting(self, monkeypatch, make_view): def test_sends_notify_without_catalog_zone(self, monkeypatch, make_view):
# When the CatalogZone for the view doesn't exist, from netbox_dns_bridge.models import SeenTransferClients
# the job should log an error and not send any notifies from django.utils import timezone
view = make_view(name="missing-serial-view")
view = make_view(name="no-catz-view")
SeenTransferClients.objects.create(
source_ip="192.0.2.1", view=view, last_seen=timezone.now()
)
monkeypatch.setattr(nh, "SETTINGS", { monkeypatch.setattr(nh, "SETTINGS", {
"notify_clients": True,
"tsig_keys": { "tsig_keys": {
"missing-serial-view": { "no-catz-view": {
"keyname": "nk.", "keyname": "nk.",
"secret": "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=", "secret": "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=",
"algorithm": "hmac-sha256", "algorithm": "hmac-sha256",
@@ -256,15 +310,15 @@ class TestSendClientDNSNotifyRun:
job = MagicMock() job = MagicMock()
with patch("dns.query.udp") as mock_udp: with patch("dns.query.udp") as mock_udp:
nh.SendClientDNSNotify.run(job, nh.SendClientNotify.run(job,
zone_name="z.test", soa_serial=1, view_name="missing-serial-view") zone_name="z.test", soa_serial=1, view_name="no-catz-view")
mock_udp.assert_not_called() assert mock_udp.call_count == 1
@pytest.mark.django_db @pytest.mark.django_db
class TestSendNSNotifyRun: class TestSendNSNotifyRun:
def test_sends_notify_to_nameserver_ips(self, make_zone, make_record, make_nameserver, monkeypatch): def test_sends_notify_to_nameserver_ips(self, make_zone, make_record, make_nameserver, monkeypatch):
import netbox_dns_bridge.signals.notify_handler as nh import netbox_dns_bridge.jobs.notify as nh
zone = make_zone(name="ns-notify-run.test", soa_serial=55) zone = make_zone(name="ns-notify-run.test", soa_serial=55)
ns = make_nameserver("ns1.ns-notify-run.test") ns = make_nameserver("ns1.ns-notify-run.test")
@@ -319,3 +373,44 @@ class TestSendNSNotifyRun:
zone_id=zone.pk, zone_name="ns-notify-empty.test", zone_id=zone.pk, zone_name="ns-notify-empty.test",
view_name=zone.view.name, soa_serial=1) view_name=zone.view.name, soa_serial=1)
mock_udp.assert_not_called() mock_udp.assert_not_called()
@pytest.mark.django_db
class TestNotifyNsEnabled:
def test_enabled_by_all_zones_flag(self, make_zone, monkeypatch):
monkeypatch.setattr(nh, "SETTINGS", {"notify_ns_all_zones": True})
from netbox_dns_bridge.jobs.notify import _notify_ns_enabled
zone = make_zone(name="nsflag.test")
assert _notify_ns_enabled(zone) is True
def test_disabled_by_default(self, make_zone, monkeypatch):
monkeypatch.setattr(nh, "SETTINGS", {})
from netbox_dns_bridge.jobs.notify import _notify_ns_enabled
zone = make_zone(name="nsdisabled.test")
assert _notify_ns_enabled(zone) is False
def test_enabled_by_custom_field(self, make_zone, monkeypatch):
monkeypatch.setattr(nh, "SETTINGS", {"notify_ns_custom_field_name": "enable_notify"})
from netbox_dns_bridge.jobs.notify import _notify_ns_enabled
zone = make_zone(name="nscf.test")
zone.custom_field_data = {"enable_notify": True}
assert _notify_ns_enabled(zone) is True
def test_disabled_when_custom_field_false(self, make_zone, monkeypatch):
monkeypatch.setattr(nh, "SETTINGS", {"notify_ns_custom_field_name": "enable_notify"})
from netbox_dns_bridge.jobs.notify import _notify_ns_enabled
zone = make_zone(name="nscf-false.test")
zone.custom_field_data = {"enable_notify": False}
assert _notify_ns_enabled(zone) is False
def test_disabled_when_custom_field_missing(self, make_zone, monkeypatch):
monkeypatch.setattr(nh, "SETTINGS", {"notify_ns_custom_field_name": "enable_notify"})
from netbox_dns_bridge.jobs.notify import _notify_ns_enabled
zone = make_zone(name="nscf-missing.test")
zone.custom_field_data = {}
assert _notify_ns_enabled(zone) is False
+36 -63
View File
@@ -50,41 +50,55 @@ class TestZonePostSaveCreate:
obj = CatalogZone.objects.get(view=zone.view) obj = CatalogZone.objects.get(view=zone.view)
assert obj.soa_serial == catz_serial assert obj.soa_serial == catz_serial
def test_schedule_client_notify_called_when_enabled(self, make_zone, patch_settings): def test_schedule_client_notify_always_called(self, make_zone, patch_settings):
patch_settings({"notify_clients": True}) patch_settings({"notify_clients": True})
with patch( with patch(
"netbox_dns_bridge.signals.netbox_dns_zones.notify_handler.schedule_client_notify" "netbox_dns_bridge.jobs.notify.schedule_client_notify"
) as mock_notify: ) as mock_notify:
with patch( with patch("netbox_dns_bridge.jobs.notify.schedule_ns_notify"):
"netbox_dns_bridge.signals.netbox_dns_zones.notify_handler.schedule_ns_notify" with patch(
): "netbox_dns_bridge.management.commands.dns_transfer_endpoint."
with captureOnCommitCallbacks(execute=True): "catalog_zone_manager.notify.schedule_catalog_zone_notify"
make_zone(name="notify.test") ):
mock_notify.assert_called_once() with captureOnCommitCallbacks(execute=True):
make_zone(name="notify.test")
mock_notify.assert_called_once()
def test_schedule_ns_notify_called_when_serial_changed(self, make_zone, patch_settings): def test_schedule_ns_notify_called_when_serial_changed(
self, make_zone, patch_settings
):
patch_settings({ patch_settings({
"notify_clients": False, "notify_clients": False,
"notify_ns_all_zones": True, "notify_ns_all_zones": True,
}) })
with patch( with patch(
"netbox_dns_bridge.signals.netbox_dns_zones.notify_handler.schedule_ns_notify" "netbox_dns_bridge.jobs.notify.schedule_ns_notify"
) as mock_ns_notify: ) as mock_ns_notify:
with captureOnCommitCallbacks(execute=True): with patch("netbox_dns_bridge.jobs.notify.schedule_client_notify"):
make_zone(name="nsnotify.test") with patch(
mock_ns_notify.assert_called_once() "netbox_dns_bridge.management.commands.dns_transfer_endpoint."
"catalog_zone_manager.notify.schedule_catalog_zone_notify"
):
with captureOnCommitCallbacks(execute=True):
make_zone(name="nsnotify.test")
mock_ns_notify.assert_called_once()
def test_schedule_ns_notify_not_called_when_disabled(self, make_zone, patch_settings): def test_schedule_ns_notify_not_called_when_serial_unchanged(
patch_settings({ self, make_zone, patch_settings
"notify_clients": False, ):
"notify_ns_all_zones": False, patch_settings({"notify_ns_all_zones": True})
})
with patch( with patch(
"netbox_dns_bridge.signals.netbox_dns_zones.notify_handler.schedule_ns_notify" "netbox_dns_bridge.jobs.notify.schedule_ns_notify"
) as mock_ns_notify: ) as mock_ns_notify:
with captureOnCommitCallbacks(execute=True): with patch("netbox_dns_bridge.jobs.notify.schedule_client_notify"):
make_zone(name="nonsnotify.test") with patch(
mock_ns_notify.assert_not_called() "netbox_dns_bridge.management.commands.dns_transfer_endpoint."
"catalog_zone_manager.notify.schedule_catalog_zone_notify"
):
zone = make_zone(name="noserialchange.test")
with captureOnCommitCallbacks(execute=True):
zone.save()
mock_ns_notify.assert_not_called()
@pytest.mark.django_db @pytest.mark.django_db
@@ -224,44 +238,3 @@ class TestSignalDedup:
view=zone.view view=zone.view
).soa_serial ).soa_serial
assert new_serial == original_serial + 2 assert new_serial == original_serial + 2
@pytest.mark.django_db
class TestNotifyNsEnabled:
def test_enabled_by_all_zones_flag(self, make_zone, patch_settings):
patch_settings({"notify_ns_all_zones": True})
from netbox_dns_bridge.signals.netbox_dns_zones import _notify_ns_enabled
zone = make_zone(name="nsflag.test")
assert _notify_ns_enabled(zone) is True
def test_disabled_by_default(self, make_zone, patch_settings):
patch_settings({})
from netbox_dns_bridge.signals.netbox_dns_zones import _notify_ns_enabled
zone = make_zone(name="nsdisabled.test")
assert _notify_ns_enabled(zone) is False
def test_enabled_by_custom_field(self, make_zone, patch_settings):
patch_settings({"notify_ns_custom_field_name": "enable_notify"})
from netbox_dns_bridge.signals.netbox_dns_zones import _notify_ns_enabled
zone = make_zone(name="nscf.test")
zone.custom_field_data = {"enable_notify": True}
assert _notify_ns_enabled(zone) is True
def test_disabled_when_custom_field_false(self, make_zone, patch_settings):
patch_settings({"notify_ns_custom_field_name": "enable_notify"})
from netbox_dns_bridge.signals.netbox_dns_zones import _notify_ns_enabled
zone = make_zone(name="nscf-false.test")
zone.custom_field_data = {"enable_notify": False}
assert _notify_ns_enabled(zone) is False
def test_disabled_when_custom_field_missing(self, make_zone, patch_settings):
patch_settings({"notify_ns_custom_field_name": "enable_notify"})
from netbox_dns_bridge.signals.netbox_dns_zones import _notify_ns_enabled
zone = make_zone(name="nscf-missing.test")
zone.custom_field_data = {}
assert _notify_ns_enabled(zone) is False