diff --git a/CHANGELOG.md b/CHANGELOG.md index 3887554..98ae9a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,15 @@ ## Unreleased +- 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 + foreign key to `netbox_dns.models.View` and its own `soa_serial` field. Each view's + catalog zone now tracks and increments its SOA serial independently. Existing serials + are preserved during migration by copying the old global value into every view's new + `CatalogZone` row. +- Change the `CatalogZoneMemberIdentifier.name` uniqueness from globally unique to + unique per catalog zone (`UniqueConstraint(name, catalog_zone)`). A `catalog_zone` + foreign key has been added to the identifier model to enforce this. +- Remove the `dns-settings` management command, which was a generic get/set/list + interface over `IntegerKeyValueSetting` (now removed). - Defer the catalog zone SOA serial increment on zone deletion to `transaction.on_commit`, matching the create/modify path. Previously the increment ran immediately at `post_delete` time, so a rollback of the delete transaction would have left the catalog serial bumped anyway. diff --git a/netbox_dns_bridge/management/commands/dns-settings.py b/netbox_dns_bridge/management/commands/dns-settings.py deleted file mode 100644 index 050aea9..0000000 --- a/netbox_dns_bridge/management/commands/dns-settings.py +++ /dev/null @@ -1,51 +0,0 @@ -from django.core.management.base import BaseCommand, CommandError -from netbox_dns_bridge.models import IntegerKeyValueSetting - - -class Command(BaseCommand): - help = "Get or set integer key-value settings" - - def add_arguments(self, parser): - parser.add_argument( - "action", choices=["get", "set", "list"], help="Action to perform" - ) - parser.add_argument("key", nargs="?", help="Setting key") - parser.add_argument( - "value", nargs="?", type=int, help="Value to set (required for 'set')" - ) - - def handle(self, *args, **options): - action = options["action"] - key = options.get("key") - value = options.get("value") - - if action == "list": - settings = IntegerKeyValueSetting.objects.all() - if not settings.exists(): - self.stdout.write("No settings found.") - return - for setting in settings: - self.stdout.write(str(setting)) - - elif action == "get": - if not key: - raise CommandError("A key is required for GET") - try: - setting = IntegerKeyValueSetting.objects.get(key=key) - self.stdout.write(f"{setting.key}: {setting.value}") - except IntegerKeyValueSetting.DoesNotExist: - raise CommandError(f"No setting was found for {key}") - - elif action == "set": - if not key: - raise CommandError("A setting is required for SET command") - if value is None: - raise CommandError("A value is required for SET command") - try: - setting = IntegerKeyValueSetting.objects.get(key=key) - old_value = setting.value - setting.value = value - setting.save() - self.stdout.write(f"Updated {key} from {old_value} to {value}") - except IntegerKeyValueSetting.DoesNotExist: - raise CommandError(f"No setting found for {key}") diff --git a/netbox_dns_bridge/management/commands/dns_transfer_endpoint/catalog_zone_manager.py b/netbox_dns_bridge/management/commands/dns_transfer_endpoint/catalog_zone_manager.py index a0efa49..0617268 100644 --- a/netbox_dns_bridge/management/commands/dns_transfer_endpoint/catalog_zone_manager.py +++ b/netbox_dns_bridge/management/commands/dns_transfer_endpoint/catalog_zone_manager.py @@ -3,8 +3,8 @@ import dns.zone import dns.rdatatype import dns.rdataclass from netbox_dns_bridge.utils import get_logger -from netbox_dns.models import Zone as NBZone -from netbox_dns_bridge.models import IntegerKeyValueSetting, CatalogZoneMemberIdentifier +from netbox_dns.models import Zone as NBZone, View as NBView +from netbox_dns_bridge.models import CatalogZone, CatalogZoneMemberIdentifier from uuid import uuid4 from base64 import b32encode from django.db import transaction, close_old_connections, OperationalError @@ -13,31 +13,32 @@ from django.db import transaction, close_old_connections, OperationalError LOGGER = get_logger(__name__) -def increment_soa_serial() -> int: +def increment_soa_serial(view: NBView) -> int: with transaction.atomic(): try: ( soa_serial_obj, _, - ) = IntegerKeyValueSetting.objects.select_for_update().get_or_create( - key="catalog-zone-soa-serial", - defaults={"value": 1}, + ) = CatalogZone.objects.select_for_update().get_or_create( + view=view, + defaults={"soa_serial": 1}, ) - new_soa_serial = soa_serial_obj.value + 1 + new_soa_serial = soa_serial_obj.soa_serial + 1 if new_soa_serial > 0xFFFFFFFF: LOGGER.warning( - f"Catalog serial {soa_serial_obj.value} reached max — wrapping back to 1" + f"Catalog serial {soa_serial_obj.soa_serial} reached max — wrapping back to 1" ) new_soa_serial = 1 - soa_serial_obj.value = new_soa_serial + soa_serial_obj.soa_serial = new_soa_serial soa_serial_obj.save() soa_serial_obj.refresh_from_db() LOGGER.debug( - f"Catalog zone SOA serial number is now {soa_serial_obj.value}" + f"Catalog zone SOA serial for view '{view.name}' " + f"is now {soa_serial_obj.soa_serial}" ) - return soa_serial_obj.value + return soa_serial_obj.soa_serial except OperationalError as e: LOGGER.error( f"ERROR: Failed to increment the Catalog Zone's SOA serial: {e}" @@ -69,8 +70,11 @@ def create_zone(name, view_name) -> dns.zone.Zone: try: catz_identifier = nb_zone.catz_identifier except NBZone.catz_identifier.RelatedObjectDoesNotExist: + catalog_zone = _get_or_create_catalog_zone(nb_zone.view) catz_identifier = CatalogZoneMemberIdentifier.objects.create( - zone=nb_zone, name=_generate_member_identifier() + zone=nb_zone, + name=_generate_member_identifier(), + catalog_zone=catalog_zone, ) p_name = catz_identifier.name @@ -100,7 +104,8 @@ def create_zone(name, view_name) -> dns.zone.Zone: zone_root_node = zone.find_node(origin, create=True) # SOA record - soa_rdataset = _create_soa_rdataset() + view = NBView.objects.get(name=view_name) + soa_rdataset = _create_soa_rdataset(view) zone_root_node.rdatasets.append(soa_rdataset) # NS record @@ -126,23 +131,34 @@ def _generate_member_identifier() -> str: return b32encode(uuid4().bytes)[0:26].lower().decode("UTF-8") +def _get_or_create_catalog_zone(view: NBView) -> CatalogZone: + catalog_zone, _ = CatalogZone.objects.get_or_create( + view=view, defaults={"soa_serial": 1} + ) + return catalog_zone + + def update_member_identifier(zone: NBZone) -> None: try: + catalog_zone = _get_or_create_catalog_zone(zone.view) CatalogZoneMemberIdentifier.objects.update_or_create( zone=zone, - defaults={"name": _generate_member_identifier()}, + defaults={ + "name": _generate_member_identifier(), + "catalog_zone": catalog_zone, + }, ) except OperationalError as e: LOGGER.error(f"ERROR: Failed to update Catalog Zone member identifier: {e}") close_old_connections() -def _create_soa_rdataset() -> dns.rdataset: +def _create_soa_rdataset(view: NBView) -> dns.rdataset: try: - serial_obj, _ = IntegerKeyValueSetting.objects.get_or_create( - key="catalog-zone-soa-serial", defaults={"value": 1} + serial_obj, _ = CatalogZone.objects.get_or_create( + view=view, defaults={"soa_serial": 1} ) - serial = serial_obj.value + serial = serial_obj.soa_serial # SOA Record components ttl = 0 diff --git a/netbox_dns_bridge/migrations/0004_catalogzone_and_per_zone_identifier.py b/netbox_dns_bridge/migrations/0004_catalogzone_and_per_zone_identifier.py new file mode 100644 index 0000000..ead66eb --- /dev/null +++ b/netbox_dns_bridge/migrations/0004_catalogzone_and_per_zone_identifier.py @@ -0,0 +1,134 @@ +import django.db.models.deletion +from django.db import migrations, models + + +def copy_serials_and_link_identifiers(apps, schema_editor): + """Seed one CatalogZone per view from the old global serial and link identifiers.""" + IntegerKeyValueSetting = apps.get_model("netbox_dns_bridge", "IntegerKeyValueSetting") + CatalogZone = apps.get_model("netbox_dns_bridge", "CatalogZone") + CatalogZoneMemberIdentifier = apps.get_model( + "netbox_dns_bridge", "CatalogZoneMemberIdentifier" + ) + + # Preserve the current global serial; default to 1 when never initialized. + seed_serial = 1 + kv = IntegerKeyValueSetting.objects.filter(key="catalog-zone-soa-serial").first() + if kv is not None: + seed_serial = kv.value + + # Create a CatalogZone for every view that currently has at least one + # member identifier, seeding each with the old global serial. + view_ids = set( + CatalogZoneMemberIdentifier.objects.exclude(zone__view__isnull=True) + .values_list("zone__view_id", flat=True) + ) + + view_id_to_catalog_zone = {} + for view_id in view_ids: + cz, _ = CatalogZone.objects.get_or_create( + view_id=view_id, defaults={"soa_serial": seed_serial} + ) + view_id_to_catalog_zone[view_id] = cz + + # Link each identifier to the CatalogZone matching its zone's view. + for ident in CatalogZoneMemberIdentifier.objects.select_related("zone__view"): + cz = view_id_to_catalog_zone.get(ident.zone.view_id) + if cz is None: + # Defensive: identifier existed without a resolvable view. Create + # the CatalogZone on demand so the NOT NULL constraint can be set. + cz, _ = CatalogZone.objects.get_or_create( + view_id=ident.zone.view_id, defaults={"soa_serial": seed_serial} + ) + view_id_to_catalog_zone[ident.zone.view_id] = cz + ident.catalog_zone = cz + ident.save(update_fields=["catalog_zone"]) + + +def reverse_migration(apps, schema_editor): + """Best-effort reverse: restore the KV row and null the FK.""" + IntegerKeyValueSetting = apps.get_model("netbox_dns_bridge", "IntegerKeyValueSetting") + CatalogZone = apps.get_model("netbox_dns_bridge", "CatalogZone") + CatalogZoneMemberIdentifier = apps.get_model( + "netbox_dns_bridge", "CatalogZoneMemberIdentifier" + ) + + # Pick a representative serial (first catalog zone) to write back. + first = CatalogZone.objects.order_by("pk").first() + serial = first.soa_serial if first is not None else 1 + IntegerKeyValueSetting.objects.update_or_create( + key="catalog-zone-soa-serial", defaults={"value": serial} + ) + + CatalogZoneMemberIdentifier.objects.update(catalog_zone=None) + + +class Migration(migrations.Migration): + + dependencies = [ + ("netbox_dns", "0030_dnsseckeytemplate_comments_dnsseckeytemplate_owner_and_more"), + ("netbox_dns_bridge", "0003_integerkeyvaluesetting_key_unique"), + ] + + operations = [ + migrations.CreateModel( + name="CatalogZone", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False + ), + ), + ("soa_serial", models.IntegerField(default=1)), + ( + "view", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="catalog_zone", + to="netbox_dns.view", + ), + ), + ], + options={ + "ordering": ("view__name",), + }, + ), + migrations.AddField( + model_name="catalogzonememberidentifier", + name="catalog_zone", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="member_identifiers", + to="netbox_dns_bridge.catalogzone", + ), + ), + migrations.AlterField( + model_name="catalogzonememberidentifier", + name="name", + field=models.CharField(max_length=26), + ), + migrations.RunPython( + copy_serials_and_link_identifiers, + reverse_code=reverse_migration, + ), + migrations.AlterField( + model_name="catalogzonememberidentifier", + name="catalog_zone", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="member_identifiers", + to="netbox_dns_bridge.catalogzone", + ), + ), + migrations.AddConstraint( + model_name="catalogzonememberidentifier", + constraint=models.UniqueConstraint( + fields=("name", "catalog_zone"), + name="unique_name_per_catalog_zone", + ), + ), + migrations.DeleteModel( + name="IntegerKeyValueSetting", + ), + ] diff --git a/netbox_dns_bridge/models.py b/netbox_dns_bridge/models.py index 09aa5a1..056d3b2 100644 --- a/netbox_dns_bridge/models.py +++ b/netbox_dns_bridge/models.py @@ -4,14 +4,21 @@ from django.db import models import netbox_dns.models -class IntegerKeyValueSetting(models.Model): +class CatalogZone(models.Model): _netbox_private = True - key = models.CharField(max_length=64, unique=True) - value = models.IntegerField() + view = models.OneToOneField( + to=netbox_dns.models.View, + on_delete=models.CASCADE, + related_name="catalog_zone", + ) + soa_serial = models.IntegerField(default=1) + + class Meta: + ordering = ("view__name",) def __str__(self): - return f"{self.key}: {str(self.value)}" + return f"{self.view.name}: {self.soa_serial}" class CatalogZoneMemberIdentifier(models.Model): @@ -19,7 +26,6 @@ class CatalogZoneMemberIdentifier(models.Model): name = models.CharField( max_length=26, - unique=True, ) zone = models.OneToOneField( @@ -28,8 +34,20 @@ class CatalogZoneMemberIdentifier(models.Model): related_name="catz_identifier", ) + catalog_zone = models.ForeignKey( + to=CatalogZone, + on_delete=models.CASCADE, + related_name="member_identifiers", + ) + class Meta: ordering = ("name",) + constraints = [ + models.UniqueConstraint( + fields=["name", "catalog_zone"], + name="unique_name_per_catalog_zone", + ), + ] def __str__(self): return self.name diff --git a/netbox_dns_bridge/signals/netbox_dns_zones.py b/netbox_dns_bridge/signals/netbox_dns_zones.py index fcb1747..f8c33be 100644 --- a/netbox_dns_bridge/signals/netbox_dns_zones.py +++ b/netbox_dns_bridge/signals/netbox_dns_zones.py @@ -3,7 +3,7 @@ from django.dispatch import receiver from django.db import transaction from django.conf import settings from netbox.context import current_request -from netbox_dns.models import Zone +from netbox_dns.models import Zone, View from netbox_dns_bridge.management.commands.dns_transfer_endpoint import catalog_zone_manager as catzm from . import notify_handler @@ -14,15 +14,18 @@ def zone_pre_save(sender, instance, **kwargs): zone = instance if zone.pk: try: - old = sender.objects.only("name", "soa_serial").get(pk=zone.pk) + old = sender.objects.only("name", "soa_serial", "view_id").get(pk=zone.pk) zone._old_name = old.name zone._old_soa_serial = old.soa_serial + zone._old_view_id = old.view_id except sender.DoesNotExist: zone._old_name = None zone._old_soa_serial = None + zone._old_view_id = None else: zone._old_name = None zone._old_soa_serial = None + zone._old_view_id = None @receiver(post_save, sender=Zone) @@ -32,6 +35,8 @@ def zone_post_save(sender, instance, created, **kwargs): # Quit if this is not the first signal in this session for this zone. try: request = current_request.get() + if request is None: + raise LookupError handled_zones = getattr(request, "_handled_zones", set()) if zone.pk in handled_zones: return @@ -46,9 +51,25 @@ def zone_post_save(sender, instance, created, **kwargs): new_soa_serial = getattr(zone, "soa_serial", None) soa_serial_changed = created or (old_soa_serial != new_soa_serial) + # Detect whether the zone moved between views. When it does, the old + # view's catalog zone loses a member and the new view's gains one, so + # both serials must be incremented. + old_view_id = getattr(zone, "_old_view_id", None) + view_changed = ( + not created + and old_view_id is not None + and old_view_id != zone.view_id + ) + # On commit, increment soa serial and if notify is enabled, schedule bg job. def _on_commit(): - catzm.increment_soa_serial() + if view_changed: + try: + old_view = View.objects.get(pk=old_view_id) + catzm.increment_soa_serial(old_view) + except View.DoesNotExist: + pass + catzm.increment_soa_serial(zone.view) if SETTINGS.get("notify_clients", False): notify_handler.schedule_client_notify(zone) @@ -64,16 +85,18 @@ def zone_post_save(sender, instance, created, **kwargs): # If zone was created, create catz member identifier record for this zone. catzm.update_member_identifier(zone) else: - # Check if zone name has changed and change the catz member identifier if so. + # Re-create the catz member identifier when the zone name or its view + # changed. A view move leaves the identifier pointing at the wrong + # catalog zone, so it must be refreshed. old_name = getattr(zone, "_old_name", None) - if old_name and zone.name != old_name: + if (old_name and zone.name != old_name) or view_changed: catzm.update_member_identifier(zone) @receiver(post_delete, sender=Zone) def zone_post_delete(sender, instance, **kwargs): def _on_commit(): - catzm.increment_soa_serial() + catzm.increment_soa_serial(instance.view) transaction.on_commit(_on_commit) diff --git a/netbox_dns_bridge/signals/notify_handler.py b/netbox_dns_bridge/signals/notify_handler.py index c0ebbe4..8a1507e 100644 --- a/netbox_dns_bridge/signals/notify_handler.py +++ b/netbox_dns_bridge/signals/notify_handler.py @@ -12,7 +12,7 @@ from django.db import close_old_connections, OperationalError from netbox.jobs import JobRunner from netbox_dns.models import View, Zone, Record from netbox_dns_bridge.utils import get_logger -from netbox_dns_bridge.models import SeenTransferClients, IntegerKeyValueSetting +from netbox_dns_bridge.models import SeenTransferClients, CatalogZone LOGGER = get_logger(__name__) SETTINGS = settings.PLUGINS_CONFIG.get("netbox_dns_bridge", {}) @@ -51,9 +51,12 @@ class SendClientDNSNotify(JobRunner): view_name = kwargs["view_name"] try: - catz_serial = IntegerKeyValueSetting.objects.get( - key="catalog-zone-soa-serial" - ).value + 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"]), @@ -61,7 +64,6 @@ class SendClientDNSNotify(JobRunner): (f"{view_name}.catz", catz_serial), ] - view = View.objects.get(name=view_name) tsig_key = _load_tsig_key(view_name) cutoff_hours = SETTINGS.get("notify_client_alive_threshold_hours", 24) seen_cutoff = timezone.now() - timezone.timedelta(hours=cutoff_hours) @@ -103,8 +105,8 @@ class SendClientDNSNotify(JobRunner): f" {view_name}/{zone_name} {soa_serial}: {e}" ) - except IntegerKeyValueSetting.DoesNotExist as e: - LOGGER.error(f"Failed to get catalog zone serial: {e}") + except View.DoesNotExist as e: + LOGGER.error(f"Failed to get view for NOTIFY: {e}") except OperationalError as e: LOGGER.error(f"NOTIFY failed due to unexpected error: {e}") diff --git a/netbox_dns_bridge/templates/netbox_dns_bridge/catz_overview.html b/netbox_dns_bridge/templates/netbox_dns_bridge/catz_overview.html index efb6c02..581bbe1 100644 --- a/netbox_dns_bridge/templates/netbox_dns_bridge/catz_overview.html +++ b/netbox_dns_bridge/templates/netbox_dns_bridge/catz_overview.html @@ -7,12 +7,27 @@
-
{% trans "Catalog Zone SOA Serial Number" %}
+
{% trans "Catalog Zone SOA Serial Numbers" %}
- {% if soa_serial is not None %} - {{ soa_serial }} + {% if catalog_zones %} + + + + + + + + + {% for cz in catalog_zones %} + + + + + {% endfor %} + +
{% trans "View" %}{% trans "SOA Serial" %}
{{ cz.view.name }}{{ cz.soa_serial }}
{% else %} - {% trans "Not yet initialized." %} + {% trans "No catalog zones initialized." %} {% endif %}
diff --git a/netbox_dns_bridge/views.py b/netbox_dns_bridge/views.py index f5e645f..c5c751d 100644 --- a/netbox_dns_bridge/views.py +++ b/netbox_dns_bridge/views.py @@ -8,7 +8,7 @@ from netbox.views.generic import ObjectListView from .filtersets import ZoneFilterSet from .forms import CatalogZoneFilterForm -from .models import IntegerKeyValueSetting, SeenTransferClients +from .models import CatalogZone, SeenTransferClients from .tables import CatalogZoneTable @@ -27,10 +27,9 @@ class CatzOverviewView(ObjectListView): def get_extra_context(self, request): context = super().get_extra_context(request) - soa_serial_obj = IntegerKeyValueSetting.objects.filter( - key="catalog-zone-soa-serial" - ).first() - context["soa_serial"] = soa_serial_obj.value if soa_serial_obj else None + context["catalog_zones"] = CatalogZone.objects.select_related( + "view" + ).order_by("view__name") return context