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:
1 parent
9f5e40ae48
commit
3c3a1834bc
8 files changed
+295
-204
No files matched your search
@@ -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
|
||||||
|
|||||||
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:
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
+125
-30
@@ -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
|
||||||
@@ -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
|
|
||||||
Reference in new issue
Block a user