diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3f160cca..3e94285f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -71,3 +71,80 @@ jobs: run: > NETBOX_CONFIGURATION=netbox_dns.tests.configuration python netbox/manage.py test netbox_dns.tests --parallel -v2 + + test-netbox-branching: + # Exercises the NetBox Branching integration (netbox_dns.tests.branching). + # These tests provision real branch schemas, so they run separately from + # the main suite: without --parallel, with netbox-branching installed and + # a branching-aware configuration. + runs-on: ubuntu-latest + + services: + redis: + image: redis + ports: + - 6379:6379 + + postgres: + image: postgres + env: + POSTGRES_USER: netbox + POSTGRES_PASSWORD: netbox + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - name: Checkout code + uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 + with: + path: netbox-plugin-dns + + - name: Set up Python + uses: actions/setup-python@4f41a90a1f38628c7ccc608d05fbafe701bc20ae + with: + python-version: "3.12" + + - name: Checkout NetBox + uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 + with: + repository: "netbox-community/netbox" + path: netbox + ref: main + + - name: Install NetBox DNS + working-directory: netbox-plugin-dns + run: | + # include tests directory for test + sed -i 's/exclude/#exclude/g' pyproject.toml + pip install . + + - name: Install dependencies + working-directory: netbox + run: | + python -m pip install --upgrade pip + python -m pip install tblib + pip install -r requirements.txt -U + # register_branching_resolver (the API this integration relies on) + # is available in netbox-branching >= 1.0.4. + pip install 'netboxlabs-netbox-branching>=1.0.4' + + - name: Tune PostgreSQL for branching test performance + run: | + sudo apt-get install -y -q postgresql-client + PGPASSWORD=netbox psql -h localhost -U netbox \ + -c "ALTER SYSTEM SET checkpoint_timeout = '30min'" \ + -c "ALTER SYSTEM SET max_wal_size = '2GB'" \ + -c "ALTER SYSTEM SET synchronous_commit = off" \ + -c "ALTER SYSTEM SET lock_timeout = '60s'" \ + -c "SELECT pg_reload_conf()" + + - name: Run branching tests + working-directory: netbox + run: > + NETBOX_CONFIGURATION=netbox_dns.tests.configuration_branching + python netbox/manage.py test netbox_dns.tests.branching --keepdb -v2 diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index ccbe50d6..f8ae420b 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -1,20 +1,21 @@ # Compatibility Matrix -| Release | Minimum NetBox Version | Maximum NetBox Version | -|----------|------------------------|------------------------| -| 1.0.x | 4.0.0 | 4.1.11 | -| 1.1.x | 4.0.0 | 4.1.11 | -| 1.2.x | 4.2.0 | 4.2.9 | -| 1.3.0 | 4.3.0 | 4.3.1 | -| 1.3.1 | 4.3.0 | 4.3.1 | -| 1.3.2-3 | 4.3.2 | 4.3.2 | -| 1.3.4-6 | 4.3.2 | 4.3.7 | -| 1.4.0 | 4.3.2 | 4.3.7 | -| 1.4.1-7 | 4.3.2 | 4.4.10 | -| 1.5.0-1 | 4.5.0 | 4.5.1 | -| 1.5.2-3 | 4.5.2 | 4.5.0-6 | -| 1.5.4 | 4.5.4 | 4.5.0-6 | -| 1.5.5 | 4.5.0 | 4.5.0-6 | -| 1.5.6 | 4.5.0 | 4.5.0+ | -| 1.5.7 | 4.5.3 | 4.5.3+, 4.6.0+ | -| 1.5.8-10 | 4.5.0 | 4.5.0+, 4.6.0+ | +| Release | Minimum NetBox Version | Maximum NetBox Version | +|-----------|------------------------|------------------------| +| 1.0.x | 4.0.0 | 4.1.11 | +| 1.1.x | 4.0.0 | 4.1.11 | +| 1.2.x | 4.2.0 | 4.2.9 | +| 1.3.0 | 4.3.0 | 4.3.1 | +| 1.3.1 | 4.3.0 | 4.3.1 | +| 1.3.2-3 | 4.3.2 | 4.3.2 | +| 1.3.4-6 | 4.3.2 | 4.3.7 | +| 1.4.0 | 4.3.2 | 4.3.7 | +| 1.4.1-7 | 4.3.2 | 4.4.10 | +| 1.5.0-1 | 4.5.0 | 4.5.1 | +| 1.5.2-3 | 4.5.2 | 4.5.0-6 | +| 1.5.4 | 4.5.4 | 4.5.0-6 | +| 1.5.5 | 4.5.0 | 4.5.0-6 | +| 1.5.6 | 4.5.0 | 4.5.0+ | +| 1.5.7 | 4.5.3 | 4.5.3+, 4.6.0+ | +| 1.5.8-10 | 4.5.0 | 4.5.0+, 4.6.0+ | +| 1.6-beta1 | 4.5.0 | 4.5.0+, 4.6.0+ | diff --git a/netbox_dns/__init__.py b/netbox_dns/__init__.py index ce24aa53..d32c1f39 100644 --- a/netbox_dns/__init__.py +++ b/netbox_dns/__init__.py @@ -4,7 +4,10 @@ from netbox.plugins import PluginConfig from netbox.plugins.utils import get_plugin_config -__version__ = "1.5.10" +__version__ = "1.6-beta1" + + +_branching_hooks_registered = False def _check_list(setting): @@ -12,6 +15,56 @@ def _check_list(setting): raise ImproperlyConfigured(f"{setting} must be a list") +def _register_branching_hooks(): + """ + Register NetBox Branching integration hooks, if the plugin is installed. + + No-op when netbox-branching is absent, so NetBox DNS keeps working without + it. Idempotent: a module-level guard keeps repeated ``ready()`` invocations + from registering the resolver more than once. + """ + global _branching_hooks_registered + if _branching_hooks_registered: + return + + # All-or-nothing and graceful: if netbox-branching is absent or too old to + # expose the APIs this integration needs, register nothing rather than + # leave a half-wired integration (or break plugin startup). + try: + from netbox_branching.signals import ( + post_merge, + post_revert, + post_sync, + pre_merge, + pre_revert, + pre_sync, + ) + from netbox_branching.utilities import register_branching_resolver + except ImportError: + return + + from netbox_dns.branching import ( + _enter_replay, + _exit_replay, + supports_branching_resolver, + ) + + # Route the DNS m2m through models (and every other DNS model) to the + # active branch's schema. + register_branching_resolver(supports_branching_resolver) + + # Suppress the DNS reconciliation side effects while a branch is being + # merged / synced / reverted, so the replayed changelog isn't duplicated by + # freshly-generated managed records. ``weak=False`` keeps the receivers + # alive past the end of ``ready()``. + for signal in (pre_merge, pre_sync, pre_revert): + signal.connect(_enter_replay, weak=False) + for signal in (post_merge, post_sync, post_revert): + signal.connect(_exit_replay, weak=False) + + _branching_hooks_registered = True + + class DNSConfig(PluginConfig): name = "netbox_dns" verbose_name = _("NetBox DNS") @@ -121,6 +174,8 @@ class DNSConfig(PluginConfig): def ready(self): super().ready() + _register_branching_hooks() + import netbox_dns.signals.dnssec # noqa: F401 if get_plugin_config("netbox_dns", "enable_record_expiration_job"): diff --git a/netbox_dns/branching.py b/netbox_dns/branching.py new file mode 100644 index 00000000..f0886f18 --- /dev/null +++ b/netbox_dns/branching.py @@ -0,0 +1,145 @@ +""" +Integration hooks for the `NetBox Branching`_ plugin. + +NetBox Branching decides whether a model's rows live in a branch's schema by +checking whether the model uses NetBox's change-logging mixin +(``ChangeLoggingMixin``). That heuristic misses the auto-generated +many-to-many *through* models behind fields such as ``Zone.nameservers`` and +``View.prefixes``: those are plain ``django.db.models.Model`` subclasses, so +without help Branching routes their queries to the *main* schema even while a +branch is active. + +The consequences are exactly the merge failures reported against the plugin: +the m2m rows written inside a branch (e.g. the nameservers assigned to a zone) +land in main rather than the branch, and the reconciliation logic in +``Zone.update_ns_records()`` / ``NameServer.save()`` -- which reads a zone's own +nameservers and NS records back out through those relations -- sees the wrong +schema. When the branch is later merged the change set doesn't apply cleanly. + +The resolver below marks every NetBox DNS model -- both the change-logged +primary models and their m2m through models -- as branchable, so all of their +queries are routed to the active branch's schema and the branch merges cleanly. + +Everything here is a no-op unless netbox-branching is installed; the caller in +``DNSConfig.ready()`` guards registration with ``try/except ImportError``. + +.. _NetBox Branching: https://github.com/netboxlabs/netbox-branching +""" + +import threading +from functools import cache + +__all__ = ( + "dns_branch_replay_active", + "supports_branching_resolver", +) + +APP_LABEL = "netbox_dns" + + +# ── Replay guard ───────────────────────────────────────────────────────────── +# +# NetBox DNS keeps derived data -- the managed SOA and NS records, PTR records, +# and auto-incrementing SOA serials -- in sync through side effects in the +# ``save()`` / ``delete()`` methods and ``m2m_changed`` receivers of its models. +# +# When NetBox Branching merges (or syncs, or reverts) a branch it *replays* the +# branch's recorded changes onto the target schema. Those recorded changes +# already include every derived record the side effects produced inside the +# branch. If the side effects run again during replay they recreate that +# derived data a second time, colliding with the replayed copy -- e.g. "there +# is already an active SOA record for name @". This is the root cause of the +# reported merge failures. +# +# The flag below is raised for the duration of a merge / sync / revert (via the +# Branching pre-/post- signals wired up in ``_register_branching_hooks``). +# While it is set, the DNS models persist rows but skip their reconciliation +# side effects, letting the replayed changelog reproduce the branch state +# exactly. It is a thread-local boolean so it never bleeds across the +# concurrent requests/workers that might be running outside the merge. + +_replay_state = threading.local() + + +def dns_branch_replay_active(): + """ + Return ``True`` while a NetBox Branching merge / sync / revert is replaying + changes in the current thread. DNS model side effects check this to avoid + regenerating derived records that the replay already carries. + + Always ``False`` when netbox-branching isn't installed (nothing ever sets + the flag), so this is a cheap no-op in non-branching deployments. + """ + return getattr(_replay_state, "active", False) + + +def _enter_replay(**kwargs): + _replay_state.active = True + + +def _exit_replay(**kwargs): + _replay_state.active = False + + +@cache +def _m2m_through_models(): + """ + Return the set of auto-generated m2m *through* model classes owned by the + NetBox DNS app (e.g. the through model behind ``Zone.nameservers``). + + Only through models that live in the ``netbox_dns`` app are returned. + Shared NetBox through models reachable from the DNS models -- notably + ``extras.TaggedItem`` behind the ``tags`` field -- are excluded: those are + branch-aware in their own right (Branching lists ``extras.taggeditem`` in + its ``INCLUDE_MODELS``), so they must not be claimed here. + + Computed once and cached; the app registry is fully populated by the time a + resolver is first invoked, so iterating it here is safe. + """ + from django.apps import apps + + through_models = set() + for model in apps.get_app_config(APP_LABEL).get_models(): + for field in model._meta.local_many_to_many: + through = getattr(field.remote_field, "through", None) + if through is not None and through._meta.app_label == APP_LABEL: + through_models.add(through) + + return through_models + + +def supports_branching_resolver(model): + """ + Branching resolver registered via + ``netbox_branching.utilities.register_branching_resolver``. + + Signature: ``resolver(model) -> bool | None`` + + * ``True`` -- model is branchable, route its queries to the active branch + * ``None`` -- defer to the next resolver / Branching's default heuristic + + A NetBox DNS model is branchable when it either + + * inherits NetBox's ``ChangeLoggingMixin`` (all of the plugin's primary + models do, via ``PrimaryModel``), or + * is one of the auto-generated m2m through models (plain ``models.Model`` + subclasses that Branching's default heuristic would otherwise route to + the main schema, breaking merges). + + Models from other apps -- including the m2m *targets* that live outside + ``netbox_dns`` such as ``ipam.Prefix`` -- return ``None`` so their own + change-logging status (and any other plugin's resolver) decides. + """ + from netbox.models.features import ChangeLoggingMixin + + meta = getattr(model, "_meta", None) + if meta is None or meta.app_label != APP_LABEL: + return None + + if issubclass(model, ChangeLoggingMixin): + return True + + if model in _m2m_through_models(): + return True + + return None diff --git a/netbox_dns/models/nameserver.py b/netbox_dns/models/nameserver.py index cb216368..be7e7723 100644 --- a/netbox_dns/models/nameserver.py +++ b/netbox_dns/models/nameserver.py @@ -9,6 +9,7 @@ from netbox.models.features import ContactsMixin from netbox.plugins.utils import get_plugin_config from netbox.search import SearchIndex, register_search +from netbox_dns.branching import dns_branch_replay_active from netbox_dns.choices import RecordTypeChoices from netbox_dns.mixins import ObjectModificationMixin from netbox_dns.utilities import ( @@ -72,6 +73,10 @@ def display_name(self): return name_to_unicode(self.name) def clean_fields(self, exclude=None): + if dns_branch_replay_active(): + super().clean_fields(exclude=exclude) + return + if get_plugin_config("netbox_dns", "convert_names_to_lowercase", False): self.name = self.name.lower() @@ -80,6 +85,12 @@ def clean_fields(self, exclude=None): clean_fields.alters_data = True def clean(self, *args, **kwargs): + if dns_branch_replay_active(): + # See save(): the name is already normalised/validated in the + # branch; skip model-level validation during a merge/sync/revert + # replay. + return + try: self.name = normalize_name(self.name) except NameFormatError as exc: @@ -103,6 +114,13 @@ def clean(self, *args, **kwargs): clean.alters_data = True def save(self, *args, **kwargs): + if dns_branch_replay_active(): + # Replaying a NetBox Branching merge/sync/revert: persist the row + # only. The dependent zones' SOA/NS record updates are replayed + # from the branch changelog. See netbox_dns.branching. + super().save(*args, **kwargs) + return + self.full_clean() changed_fields = self.changed_fields @@ -120,6 +138,12 @@ def save(self, *args, **kwargs): zone.update_ns_records() def delete(self, *args, **kwargs): + if dns_branch_replay_active(): + # See save(): the managed NS record cleanup is replayed from the + # branch changelog, so drop the row only. + super().delete(*args, **kwargs) + return + with transaction.atomic(): for zone in self.zones.all(): zone.records.filter( diff --git a/netbox_dns/models/record.py b/netbox_dns/models/record.py index e1228342..4ee6eef2 100644 --- a/netbox_dns/models/record.py +++ b/netbox_dns/models/record.py @@ -16,6 +16,7 @@ from netbox.models.features import ContactsMixin from netbox.plugins.utils import get_plugin_config from netbox.search import SearchIndex, register_search +from netbox_dns.branching import dns_branch_replay_active from netbox_dns.choices import ( RecordClassChoices, RecordStatusChoices, @@ -953,6 +954,13 @@ def clean_fields(self, exclude=None): clean_fields.alters_data = True def clean(self, *args, new_zone=None, **kwargs): + if dns_branch_replay_active(): + # Replaying a NetBox Branching merge/sync/revert: the record was + # already validated in the branch, and re-running the uniqueness / + # zone-cut checks here (e.g. against the managed records the replay + # also carries) would spuriously fail. See netbox_dns.branching. + return + self.validate_name(new_zone=new_zone) self.validate_value() self.check_unique_record(new_zone=new_zone) @@ -1060,6 +1068,14 @@ def save( update_rrset_ttl=True, **kwargs, ): + if dns_branch_replay_active(): + # Replaying a NetBox Branching merge/sync/revert: persist the row + # only. The PTR records and zone serial bumps this method would + # trigger are replayed from the branch changelog. See + # netbox_dns.branching. + super().save(*args, **kwargs) + return + self.full_clean() if not self._state.adding and update_rrset_ttl: @@ -1102,6 +1118,12 @@ def save( self.zone.update_serial(save_zone_serial=save_zone_serial) def delete(self, *args, save_zone_serial=True, **kwargs): + if dns_branch_replay_active(): + # See save(): PTR/serial reconciliation is replayed from the branch + # changelog, so drop the row only. + super().delete(*args, **kwargs) + return + if self.rfc2317_cname_record: self.remove_from_rfc2317_cname_record(save_zone_serial=save_zone_serial) diff --git a/netbox_dns/models/zone.py b/netbox_dns/models/zone.py index 0003fa65..3ba6cbd9 100644 --- a/netbox_dns/models/zone.py +++ b/netbox_dns/models/zone.py @@ -25,6 +25,7 @@ from netbox.models.features import ContactsMixin from netbox.plugins.utils import get_plugin_config from netbox.search import SearchIndex, register_search +from netbox_dns.branching import dns_branch_replay_active from netbox_dns.choices import ( RecordClassChoices, RecordTypeChoices, @@ -776,6 +777,14 @@ def update_rfc2317_parent_zone(self): ptr_zone.update_soa_record() def clean_fields(self, exclude=None): + if dns_branch_replay_active(): + # During a NetBox Branching replay the field values already come + # from the validated branch changelog; skip the DNS-specific + # defaulting (default view, SOA defaults, name normalization) and + # run only Django's built-in field validation. See netbox_dns.branching. + super().clean_fields(exclude=exclude) + return + defaults = settings.PLUGINS_CONFIG.get("netbox_dns") if get_plugin_config("netbox_dns", "convert_names_to_lowercase", False): @@ -815,6 +824,15 @@ def clean_fields(self, exclude=None): clean_fields.alters_data = True def clean(self, *args, **kwargs): + if dns_branch_replay_active(): + # See clean_fields(): the object is applied from the validated + # branch changelog, so skip model-level validation entirely while + # replaying a merge/sync/revert. (Running it would re-check DNS + # invariants against the managed records the replay also carries, + # and choke on the partial objects branching reconstructs when + # reverting an update.) + return + if not self.dnssec_policy: self.parental_agents = self._meta.get_field("parental_agents").get_default() @@ -971,6 +989,14 @@ def clean(self, *args, **kwargs): clean.alters_data = True def save(self, *args, **kwargs): + if dns_branch_replay_active(): + # A NetBox Branching merge/sync/revert is replaying recorded + # changes; persist the row only and let the changelog reproduce the + # managed SOA/NS records, PTR records and serial bumps this method + # would otherwise regenerate (and duplicate). See netbox_dns.branching. + super().save(*args, **kwargs) + return + self.full_clean() changed_fields = self.changed_fields @@ -1159,6 +1185,11 @@ def delete(self, *args, **kwargs): @receiver(m2m_changed, sender=Zone.nameservers.through) def update_ns_records(**kwargs): + if dns_branch_replay_active(): + # See Zone.save(): the managed NS records are replayed from the branch + # changelog, so don't regenerate them here. + return + if kwargs.get("action") not in ["post_add", "post_remove"]: return diff --git a/netbox_dns/signals/dnssec.py b/netbox_dns/signals/dnssec.py index dec9b627..de72080b 100644 --- a/netbox_dns/signals/dnssec.py +++ b/netbox_dns/signals/dnssec.py @@ -3,6 +3,7 @@ from django.dispatch import receiver from netbox.context import current_request +from netbox_dns.branching import dns_branch_replay_active from netbox_dns.models import DNSSECKeyTemplate, DNSSECPolicy from netbox_dns.validators import validate_key_template_assignment from utilities.exceptions import AbortRequest @@ -10,6 +11,11 @@ @receiver(m2m_changed, sender=DNSSECPolicy.key_templates.through) def dnssec_policy_key_templates_changed(action, instance, pk_set, **kwargs): + if dns_branch_replay_active(): + # The assignment was already validated when it was made in the branch; + # don't re-validate while replaying a merge/sync/revert. + return + request = current_request.get() key_templates = instance.key_templates.all() diff --git a/netbox_dns/tests/branching/__init__.py b/netbox_dns/tests/branching/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/netbox_dns/tests/branching/base.py b/netbox_dns/tests/branching/base.py new file mode 100644 index 00000000..5e93e1a8 --- /dev/null +++ b/netbox_dns/tests/branching/base.py @@ -0,0 +1,236 @@ +""" +Shared infrastructure for the NetBox DNS branching integration tests. + +These tests exercise the branching resolver registered by +``netbox_dns.branching`` end-to-end: they provision a real NetBox Branching +branch (a separate PostgreSQL schema), make DNS changes inside it, and merge / +revert the branch back to main. + +They are skipped automatically when netbox-branching is not installed, so the +plugin's regular test suite stays clean in environments that don't use it. + +``TransactionTestCase`` (not ``TestCase``) is required: branch schemas live in +separate PostgreSQL schemas backed by distinct database connections that cannot +be rolled back inside a single SAVEPOINT-based transaction. +""" + +import logging +import os +import time +import uuid + +from django.apps import apps as django_apps +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.management import create_contenttypes +from django.db import connection, connections +from django.test import RequestFactory, TransactionTestCase +from django.urls import reverse + +try: + from netbox_branching.choices import BranchStatusChoices + from netbox_branching.models import Branch + + HAS_BRANCHING = True +except ImportError: + HAS_BRANCHING = False + +logger = logging.getLogger(__name__) +User = get_user_model() + +# When netbox-branching is not installed, use ``object`` as the concrete base so +# Django's test runner doesn't try to run the (skipped) TransactionTestCase +# machinery — keeping it fully isolated from the plugin's other test modules. +TestBase = TransactionTestCase if HAS_BRANCHING else object + +# Provisioning timeout for branch tests. Override via the +# ``NETBOX_DNS_BRANCH_PROVISION_TIMEOUT`` env var (seconds) when CI is slow. +BRANCH_PROVISION_TIMEOUT = float( + os.environ.get("NETBOX_DNS_BRANCH_PROVISION_TIMEOUT", "30") +) + + +def make_request(user): + """Return a fresh request object suitable for ``event_tracking``.""" + request = RequestFactory().get(reverse("home")) + request.id = uuid.uuid4() + request.user = user + return request + + +def provision_branch(name, merge_strategy=None, user=None, timeout=None): + """ + Create a branch and wait for it to reach ``READY`` status. + + ``merge_strategy`` is optional (the ``Branch`` field is nullable); only + tests that actually merge or revert need to pass one. + """ + if timeout is None: + timeout = BRANCH_PROVISION_TIMEOUT + + branch = Branch(name=name, merge_strategy=merge_strategy) + branch.save(provision=False) + branch.provision(user=user) + + deadline = time.time() + timeout + while time.time() < deadline: + branch.refresh_from_db() + if branch.status == BranchStatusChoices.READY: + return branch + time.sleep(0.1) + + raise TimeoutError( + f"Branch {name!r} did not reach READY within {timeout:.0f}s " + f"(status={branch.status!r})" + ) + + +def _recreate_contenttypes(): + """ + Recreate ContentType rows for all installed apps. + + ``TransactionTestCase`` flushes every table between tests; recreating the + ContentTypes (idempotently, via ``get_or_create``) lets subsequent test + classes look up ObjectType rows again without the duplicate-key violations + that ``serialized_rollback`` can cause under the parallel runner. + """ + for app_config in django_apps.get_app_configs(): + create_contenttypes(app_config, verbosity=0) + + +def _close_branch_connections(): + """Best-effort close of any open branch database connections.""" + from django.db.utils import DatabaseError + + for branch in Branch.objects.all(): + try: + connections[branch.connection_name].close() + except DatabaseError: + logger.debug( + "failed to close branch connection %r", + branch.connection_name, + exc_info=True, + ) + + +def _drop_branch_schemas(): + """ + Drop leftover NetBox Branching branch schemas before the database flush. + + Each provisioned branch gets its own PostgreSQL schema holding copies of + branch-aware tables that carry FK references into the main schema. If a + test errors before deleting its branch, ``TransactionTestCase``'s TRUNCATE + fails with "cannot truncate a table referenced in a foreign key + constraint". In the test database the only non-system schemas are branch + schemas, so dropping all of them is safe. + """ + # Close all non-default connections; branch connections may still be open. + for alias in list(connections): + if alias != "default": + try: + connections[alias].close() + except Exception: + pass + + try: + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT schema_name FROM information_schema.schemata + WHERE schema_name NOT IN ( + 'public', 'pg_catalog', 'information_schema', 'pg_toast' + ) + AND schema_name NOT LIKE 'pg_%%' + """ + ) + schemas = [row[0] for row in cursor.fetchall()] + + if not schemas: + return + + with connection.cursor() as cursor: + cursor.execute("SET lock_timeout = '10s'") + for schema in schemas: + try: + cursor.execute(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE') + except Exception: + logger.warning( + "could not drop branch schema %r", schema, exc_info=True + ) + except Exception: + logger.warning("_drop_branch_schemas failed", exc_info=True) + + +def _reset_netbox_request_context(): + """ + Clear NetBox's request-scoped ContextVars. + + ``netbox.context_managers.event_tracking()`` sets ``current_request`` on + entry but only clears it after ``yield`` — so an exception inside the + ``with`` block leaves it pointing at a request whose user row is truncated + by the following flush, which would make the next test's ObjectChange + creation fail an FK check on ``user_id``. + """ + try: + from netbox.context import current_request, events_queue, query_cache + except ImportError: + return + + current_request.set(None) + events_queue.set({}) + try: + query_cache.set(None) + except AttributeError: + logger.debug("netbox.context.query_cache has no .set(); skipping reset") + + +class BranchingTestBase: + """ + Common per-test lifecycle for the branching-aware DNS test classes. + + Concrete test classes must also inherit from ``TransactionTestCase`` (via + ``TestBase``) — branch schemas can't be rolled back inside a SAVEPOINT. + """ + + def setUp(self): + super().setUp() + _reset_netbox_request_context() + _recreate_contenttypes() + self.user = User.objects.create_user(username="dns-branching-testuser") + self.request = make_request(self.user) + + # NetBox DNS ships a default View (created by a data migration) that + # ``Zone.clean_fields()`` auto-assigns when a zone is saved without an + # explicit view. TransactionTestCase flushes that migration data + # between tests, so recreate it here — before any branch is + # provisioned, so it's replicated into the branch schema too. + from netbox_dns.models import View + + View.objects.get_or_create( + default_view=True, + defaults={"name": "_default_", "description": "Default View"}, + ) + + def tearDown(self): + _reset_netbox_request_context() + # Defensively clear the branching replay guard so a test whose merge or + # revert raised (leaving post_merge/post_revert unfired) can't leak an + # active-replay flag into the next test. + from netbox_dns.branching import _exit_replay + + _exit_replay() + _close_branch_connections() + # Remove ObjectChange rows created during the test (merge/revert writes + # them into main referencing the test user); leaving them behind makes + # the serialized_rollback snapshot fail an FK check after the flush. + from core.models import ObjectChange + + ObjectChange.objects.all().delete() + super().tearDown() + + def _fixture_teardown(self): + # Drop lingering branch schemas before Django's flush TRUNCATEs the + # main-schema tables they reference, then restore ContentTypes for any + # test class that follows on the same worker. + _drop_branch_schemas() + super()._fixture_teardown() + _recreate_contenttypes() diff --git a/netbox_dns/tests/branching/test_branching.py b/netbox_dns/tests/branching/test_branching.py new file mode 100644 index 00000000..99a251ae --- /dev/null +++ b/netbox_dns/tests/branching/test_branching.py @@ -0,0 +1,589 @@ +""" +End-to-end branching integration tests for NetBox DNS. + +Each test provisions a real NetBox Branching branch, makes DNS changes inside +it, and merges / reverts the branch — asserting that the changes appear in (and +disappear from) the main schema at the right times. + +The interesting cases are the many-to-many relations: ``Zone.nameservers``, +``View.prefixes``, ``ZoneTemplate.nameservers`` / ``record_templates`` and +``DNSSECPolicy.key_templates``. Their auto-generated through models are plain +``models.Model`` subclasses; without the resolver registered in +``netbox_dns.branching`` their rows would be routed to the main schema even +inside a branch and the branch would fail to merge cleanly. Assigning +nameservers to a zone additionally drives ``Zone.update_ns_records()``, which +reads the zone's own nameservers and NS records back out through those +relations — the exact path the original bug reports pointed at. +""" + +import unittest + +from django.test import TestCase + +from netbox_dns.branching import ( + _m2m_through_models, + supports_branching_resolver, +) +from netbox_dns.choices import ( + DNSSECKeyTemplateAlgorithmChoices, + DNSSECKeyTemplateTypeChoices, + RecordTypeChoices, +) +from netbox_dns.models import ( + DNSSECKeyTemplate, + DNSSECPolicy, + NameServer, + Record, + RecordTemplate, + Registrar, + RegistrationContact, + View, + Zone, + ZoneTemplate, +) + +from .base import ( + HAS_BRANCHING, + BranchingTestBase, + TestBase, + make_request, + provision_branch, +) + +if HAS_BRANCHING: + from netbox_branching.choices import ( + BranchMergeStrategyChoices, + BranchStatusChoices, + ) + from netbox_branching.utilities import activate_branch + + from netbox.context_managers import event_tracking + + +ZONE_DEFAULTS = { + "soa_rname": "hostmaster.example.com", +} + + +# ── Resolver unit tests (no branch provisioning required) ──────────────────── + + +class BranchingResolverTestCase(TestCase): + """ + Fast unit tests for the resolver itself; these don't need a provisioned + branch, so they run even without netbox-branching installed. + """ + + def test_primary_models_are_branchable(self): + for model in ( + NameServer, + Zone, + Record, + View, + ZoneTemplate, + RecordTemplate, + DNSSECPolicy, + DNSSECKeyTemplate, + ): + with self.subTest(model=model.__name__): + self.assertIs(supports_branching_resolver(model), True) + + def test_m2m_through_models_are_branchable(self): + through_models = _m2m_through_models() + + # Sanity check: every declared DNS m2m field is represented. + self.assertIn(Zone.nameservers.through, through_models) + self.assertIn(View.prefixes.through, through_models) + self.assertIn(ZoneTemplate.nameservers.through, through_models) + self.assertIn(ZoneTemplate.record_templates.through, through_models) + self.assertIn(DNSSECPolicy.key_templates.through, through_models) + + for through in through_models: + with self.subTest(model=through.__name__): + self.assertIs(supports_branching_resolver(through), True) + + def test_foreign_models_defer(self): + # Models outside netbox_dns — including the ipam.Prefix m2m target — + # must defer (return None) rather than being claimed by this resolver. + from ipam.models import Prefix + from tenancy.models import Tenant + + for model in (Prefix, Tenant): + with self.subTest(model=model.__name__): + self.assertIsNone(supports_branching_resolver(model)) + + +# ── Shared merge / revert tests (run against every merge strategy) ─────────── + + +@unittest.skipUnless(HAS_BRANCHING, "netbox-branching is not installed") +class BaseBranchingTests(BranchingTestBase): + """ + Merge and revert tests that run against every merge strategy. + + Concrete subclasses set ``MERGE_STRATEGY`` and also inherit from + ``TestBase`` (``TransactionTestCase``). + """ + + MERGE_STRATEGY = None + + def _merge(self, branch): + branch.merge(user=self.user, commit=True) + branch.refresh_from_db() + self.assertEqual(branch.status, BranchStatusChoices.MERGED) + + def _revert(self, branch): + branch.revert(user=self.user, commit=True) + branch.refresh_from_db() + + # ── basic object lifecycle ─────────────────────────────────────────────── + + def test_simple_merge_and_revert(self): + """A NameServer + Zone created in a branch round-trips through main.""" + branch = provision_branch("Simple", self.MERGE_STRATEGY, self.user) + + with activate_branch(branch), event_tracking(self.request): + nameserver = NameServer.objects.create(name="ns1.example.com") + zone = Zone.objects.create( + name="zone1.example.com", soa_mname=nameserver, **ZONE_DEFAULTS + ) + + ns_pk, zone_pk = nameserver.pk, zone.pk + + # Nothing visible in main before the merge. + self.assertFalse(NameServer.objects.filter(pk=ns_pk).exists()) + self.assertFalse(Zone.objects.filter(pk=zone_pk).exists()) + + self._merge(branch) + + self.assertTrue(NameServer.objects.filter(pk=ns_pk).exists()) + zone_main = Zone.objects.get(pk=zone_pk) + self.assertEqual(zone_main.name, "zone1.example.com") + # The managed SOA record created on zone save came along too. + self.assertTrue( + zone_main.records.filter(type=RecordTypeChoices.SOA, managed=True).exists() + ) + + self._revert(branch) + + self.assertFalse(NameServer.objects.filter(pk=ns_pk).exists()) + self.assertFalse(Zone.objects.filter(pk=zone_pk).exists()) + + # ── everything at once ─────────────────────────────────────────────────── + + def test_comprehensive_merge_and_revert(self): + """ + Create one object of every NetBox DNS model type inside a branch, + wired together through their FK and m2m relations, then merge and + revert — asserting the whole graph appears in main on merge and + disappears again on revert. + """ + branch = provision_branch("Comprehensive", self.MERGE_STRATEGY, self.user) + + with activate_branch(branch), event_tracking(self.request): + view = View.objects.create(name="internal") + ns1 = NameServer.objects.create(name="ns1.example.com") + ns2 = NameServer.objects.create(name="ns2.example.com") + registrar = Registrar.objects.create(name="ACME Registrar", iana_id=4242) + registrant = RegistrationContact.objects.create( + contact_id="REG-1", name="Registrant" + ) + admin_c = RegistrationContact.objects.create( + contact_id="ADM-1", name="Admin" + ) + + key_template = DNSSECKeyTemplate.objects.create( + name="CSK", + type=DNSSECKeyTemplateTypeChoices.TYPE_CSK, + algorithm=DNSSECKeyTemplateAlgorithmChoices.RSASHA256, + lifetime=86400, + ) + policy = DNSSECPolicy.objects.create(name="Default policy") + policy.key_templates.add(key_template) + + record_template = RecordTemplate.objects.create( + name="www template", + record_name="www", + type=RecordTypeChoices.A, + value="10.0.0.1", + ) + zone_template = ZoneTemplate.objects.create(name="Standard") + zone_template.nameservers.add(ns1) + zone_template.record_templates.add(record_template) + + zone = Zone.objects.create( + name="example.com", + view=view, + soa_mname=ns1, + registrar=registrar, + registrant=registrant, + admin_c=admin_c, + **ZONE_DEFAULTS, + ) + zone.nameservers.set([ns1, ns2]) + record = Record.objects.create( + zone=zone, + name="www", + type=RecordTypeChoices.A, + value="10.0.0.1", + ) + + pks = { + View: view.pk, + NameServer: ns1.pk, + Registrar: registrar.pk, + RegistrationContact: registrant.pk, + DNSSECKeyTemplate: key_template.pk, + DNSSECPolicy: policy.pk, + RecordTemplate: record_template.pk, + ZoneTemplate: zone_template.pk, + Zone: zone.pk, + Record: record.pk, + } + + # Before merge: none of it exists in main. + for model, pk in pks.items(): + self.assertFalse( + model.objects.filter(pk=pk).exists(), + f"{model.__name__} must not be in main before merge", + ) + + self._merge(branch) + + # After merge: everything exists in main, with relations intact. + for model, pk in pks.items(): + self.assertTrue( + model.objects.filter(pk=pk).exists(), + f"{model.__name__} must be in main after merge", + ) + + zone_main = Zone.objects.get(pk=zone.pk) + self.assertEqual(zone_main.view_id, view.pk) + self.assertEqual(zone_main.soa_mname_id, ns1.pk) + self.assertEqual(zone_main.registrar_id, registrar.pk) + self.assertEqual(zone_main.registrant_id, registrant.pk) + self.assertEqual(zone_main.admin_c_id, admin_c.pk) + self.assertEqual( + set(zone_main.nameservers.values_list("name", flat=True)), + {"ns1.example.com", "ns2.example.com"}, + ) + self.assertEqual( + list( + DNSSECPolicy.objects.get(pk=policy.pk).key_templates.values_list( + "name", flat=True + ) + ), + ["CSK"], + ) + zt_main = ZoneTemplate.objects.get(pk=zone_template.pk) + self.assertEqual( + list(zt_main.nameservers.values_list("name", flat=True)), + ["ns1.example.com"], + ) + self.assertEqual( + list(zt_main.record_templates.values_list("name", flat=True)), + ["www template"], + ) + # The user-created A record plus the managed SOA/NS records all merged. + self.assertEqual(Record.objects.get(pk=record.pk).value, "10.0.0.1") + self.assertTrue( + zone_main.records.filter(type=RecordTypeChoices.SOA, managed=True).exists() + ) + self.assertEqual( + set( + zone_main.records.filter( + type=RecordTypeChoices.NS, managed=True, name="@" + ).values_list("value", flat=True) + ), + {"ns1.example.com.", "ns2.example.com."}, + ) + + self._revert(branch) + + # After revert: the whole graph is gone from main again. + for model, pk in pks.items(): + self.assertFalse( + model.objects.filter(pk=pk).exists(), + f"{model.__name__} must be gone from main after revert", + ) + + # ── the core m2m case: Zone.nameservers ────────────────────────────────── + + def test_zone_nameservers_m2m_merge_and_revert(self): + """ + Assigning nameservers to a zone inside a branch must merge cleanly. + + This drives the ``Zone.nameservers`` m2m through model *and* + ``Zone.update_ns_records()`` (via the ``m2m_changed`` receiver), which + reads the zone's own nameservers/NS records — the path that previously + broke because the through model wasn't branch-aware. + """ + branch = provision_branch("NS m2m", self.MERGE_STRATEGY, self.user) + + with activate_branch(branch), event_tracking(self.request): + ns1 = NameServer.objects.create(name="ns1.example.com") + ns2 = NameServer.objects.create(name="ns2.example.com") + zone = Zone.objects.create( + name="example.com", soa_mname=ns1, **ZONE_DEFAULTS + ) + zone.nameservers.set([ns1, ns2]) + + # The managed NS records must exist inside the branch. + branch_ns_values = set( + zone.records.filter( + type=RecordTypeChoices.NS, managed=True, name="@" + ).values_list("value", flat=True) + ) + self.assertEqual(branch_ns_values, {"ns1.example.com.", "ns2.example.com."}) + + zone_pk = zone.pk + + # The m2m assignment must not have leaked into main before the merge. + self.assertFalse(Zone.objects.filter(pk=zone_pk).exists()) + + self._merge(branch) + + zone_main = Zone.objects.get(pk=zone_pk) + self.assertEqual( + set(zone_main.nameservers.values_list("name", flat=True)), + {"ns1.example.com", "ns2.example.com"}, + ) + ns_values = set( + zone_main.records.filter( + type=RecordTypeChoices.NS, managed=True, name="@" + ).values_list("value", flat=True) + ) + self.assertEqual(ns_values, {"ns1.example.com.", "ns2.example.com."}) + + self._revert(branch) + + self.assertFalse(Zone.objects.filter(pk=zone_pk).exists()) + + def test_zone_nameserver_removal_merge_and_revert(self): + """ + Removing a nameserver from an existing zone inside a branch merges. + + Mirrors the reported failure mode (deleting objects that have + associated DNS records inside a branch, then merging). The removal + fires ``update_ns_records()`` which deletes the managed NS record. + """ + # Seed the zone with two nameservers in main first. + ns1 = NameServer.objects.create(name="ns1.example.com") + ns2 = NameServer.objects.create(name="ns2.example.com") + zone = Zone.objects.create(name="example.com", soa_mname=ns1, **ZONE_DEFAULTS) + zone.nameservers.set([ns1, ns2]) + zone_pk = zone.pk + + branch = provision_branch("NS removal", self.MERGE_STRATEGY, self.user) + + with activate_branch(branch), event_tracking(self.request): + branch_zone = Zone.objects.get(pk=zone_pk) + # Snapshot before mutating so the ObjectChange records prechange + # data — needed for the revert path to reconstruct the prior state. + branch_zone.snapshot() + branch_zone.nameservers.remove(ns2) + + # Main still has both until the merge. + self.assertEqual(Zone.objects.get(pk=zone_pk).nameservers.count(), 2) + + self._merge(branch) + + zone_main = Zone.objects.get(pk=zone_pk) + self.assertEqual( + set(zone_main.nameservers.values_list("name", flat=True)), + {"ns1.example.com"}, + ) + ns_values = set( + zone_main.records.filter( + type=RecordTypeChoices.NS, managed=True, name="@" + ).values_list("value", flat=True) + ) + self.assertEqual(ns_values, {"ns1.example.com."}) + + self._revert(branch) + + self.assertEqual( + set( + Zone.objects.get(pk=zone_pk).nameservers.values_list("name", flat=True) + ), + {"ns1.example.com", "ns2.example.com"}, + ) + + # ── unmanaged records created inside a branch ──────────────────────────── + + def test_records_created_in_branch_merge_and_revert(self): + """Plain A records added to an existing zone inside a branch round-trip.""" + nameserver = NameServer.objects.create(name="ns1.example.com") + zone = Zone.objects.create( + name="example.com", soa_mname=nameserver, **ZONE_DEFAULTS + ) + zone.nameservers.add(nameserver) + zone_pk = zone.pk + + branch = provision_branch("Records", self.MERGE_STRATEGY, self.user) + + with activate_branch(branch), event_tracking(self.request): + branch_zone = Zone.objects.get(pk=zone_pk) + record = Record.objects.create( + zone=branch_zone, + name="www", + type=RecordTypeChoices.A, + value="10.0.0.1", + ) + + record_pk = record.pk + self.assertFalse(Record.objects.filter(pk=record_pk).exists()) + + self._merge(branch) + + record_main = Record.objects.get(pk=record_pk) + self.assertEqual(record_main.value, "10.0.0.1") + self.assertEqual(record_main.zone_id, zone_pk) + + self._revert(branch) + + self.assertFalse(Record.objects.filter(pk=record_pk).exists()) + + # ── View.prefixes m2m (cross-app target: ipam.Prefix) ──────────────────── + + def test_view_prefixes_m2m_merge_and_revert(self): + """ + ``View.prefixes`` points at ``ipam.Prefix`` (a different app). The + through model still belongs to netbox_dns and must be branch-aware. + """ + from ipam.models import Prefix + + prefix = Prefix.objects.create(prefix="10.0.0.0/24") + prefix_pk = prefix.pk + + branch = provision_branch("View prefixes", self.MERGE_STRATEGY, self.user) + + with activate_branch(branch), event_tracking(self.request): + view = View.objects.create(name="internal") + branch_prefix = Prefix.objects.get(pk=prefix_pk) + view.prefixes.add(branch_prefix) + + view_pk = view.pk + self.assertFalse(View.objects.filter(pk=view_pk).exists()) + + self._merge(branch) + + view_main = View.objects.get(pk=view_pk) + self.assertEqual( + list(view_main.prefixes.values_list("pk", flat=True)), [prefix_pk] + ) + + self._revert(branch) + + self.assertFalse(View.objects.filter(pk=view_pk).exists()) + # The pre-existing prefix must survive the revert untouched. + self.assertTrue(Prefix.objects.filter(pk=prefix_pk).exists()) + + # ── ZoneTemplate m2m relations ─────────────────────────────────────────── + + def test_zone_template_m2m_merge_and_revert(self): + """``ZoneTemplate.nameservers`` and ``.record_templates`` round-trip.""" + branch = provision_branch("Zone template", self.MERGE_STRATEGY, self.user) + + with activate_branch(branch), event_tracking(self.request): + nameserver = NameServer.objects.create(name="ns1.example.com") + record_template = RecordTemplate.objects.create( + name="A record template", + record_name="www", + type=RecordTypeChoices.A, + value="10.0.0.1", + ) + zone_template = ZoneTemplate.objects.create(name="Standard") + zone_template.nameservers.add(nameserver) + zone_template.record_templates.add(record_template) + + zt_pk = zone_template.pk + self.assertFalse(ZoneTemplate.objects.filter(pk=zt_pk).exists()) + + self._merge(branch) + + zt_main = ZoneTemplate.objects.get(pk=zt_pk) + self.assertEqual( + list(zt_main.nameservers.values_list("name", flat=True)), + ["ns1.example.com"], + ) + self.assertEqual( + list(zt_main.record_templates.values_list("name", flat=True)), + ["A record template"], + ) + + self._revert(branch) + + self.assertFalse(ZoneTemplate.objects.filter(pk=zt_pk).exists()) + + # ── DNSSECPolicy.key_templates m2m (carries its own m2m_changed signal) ── + + def test_dnssec_policy_key_templates_m2m_merge_and_revert(self): + branch = provision_branch("DNSSEC policy", self.MERGE_STRATEGY, self.user) + + with activate_branch(branch), event_tracking(self.request): + key_template = DNSSECKeyTemplate.objects.create( + name="KSK", + type=DNSSECKeyTemplateTypeChoices.TYPE_KSK, + algorithm=DNSSECKeyTemplateAlgorithmChoices.RSASHA256, + lifetime=86400, + ) + policy = DNSSECPolicy.objects.create(name="Default") + policy.key_templates.add(key_template) + + policy_pk = policy.pk + self.assertFalse(DNSSECPolicy.objects.filter(pk=policy_pk).exists()) + + self._merge(branch) + + policy_main = DNSSECPolicy.objects.get(pk=policy_pk) + self.assertEqual( + list(policy_main.key_templates.values_list("name", flat=True)), + ["KSK"], + ) + + self._revert(branch) + + self.assertFalse(DNSSECPolicy.objects.filter(pk=policy_pk).exists()) + + +# ── Concrete strategy subclasses ───────────────────────────────────────────── + + +@unittest.skipUnless(HAS_BRANCHING, "netbox-branching is not installed") +class IterativeBranchingTestCase(BaseBranchingTests, TestBase): + MERGE_STRATEGY = BranchMergeStrategyChoices.ITERATIVE if HAS_BRANCHING else None + + +@unittest.skipUnless(HAS_BRANCHING, "netbox-branching is not installed") +class SquashBranchingTestCase(BaseBranchingTests, TestBase): + MERGE_STRATEGY = BranchMergeStrategyChoices.SQUASH if HAS_BRANCHING else None + + +# ── Sync (main → branch) ───────────────────────────────────────────────────── + + +@unittest.skipUnless(HAS_BRANCHING, "netbox-branching is not installed") +class BranchSyncTestCase(BranchingTestBase, TestBase): + """Changes made in main after provisioning become visible after ``sync()``.""" + + def test_main_changes_synced_to_branch(self): + branch = provision_branch( + "Sync", BranchMergeStrategyChoices.ITERATIVE, self.user + ) + + # Create a nameserver in main *after* the branch was provisioned. It + # must be created inside event_tracking so an ObjectChange is recorded + # for sync() to replay into the branch. + with event_tracking(make_request(self.user)): + nameserver = NameServer.objects.create(name="ns-late.example.com") + ns_pk = nameserver.pk + + # Not yet visible inside the branch. + with activate_branch(branch): + self.assertFalse(NameServer.objects.filter(pk=ns_pk).exists()) + + branch.sync(user=self.user, commit=True) + + with activate_branch(branch): + self.assertTrue(NameServer.objects.filter(pk=ns_pk).exists()) diff --git a/netbox_dns/tests/configuration_branching.py b/netbox_dns/tests/configuration_branching.py new file mode 100644 index 00000000..d82aa38c --- /dev/null +++ b/netbox_dns/tests/configuration_branching.py @@ -0,0 +1,63 @@ +################################################################### +# This file serves as a base configuration for testing the # +# NetBox Branching integration only. It is not intended for # +# production use. # +################################################################### + +from netbox_branching.utilities import DynamicSchemaDict + +ALLOWED_HOSTS = ["*"] + +# netbox-branching requires DATABASES (not the singular DATABASE) to be a +# DynamicSchemaDict, plus its database router — otherwise the plugin refuses to +# start. +DATABASES = DynamicSchemaDict( + { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": "netbox", + "USER": "netbox", + "PASSWORD": "netbox", + "HOST": "localhost", + "PORT": "", + "CONN_MAX_AGE": 300, + } + } +) + +DATABASE_ROUTERS = ["netbox_branching.database.BranchAwareRouter"] + +PLUGINS = [ + "netbox_dns", + "netbox_branching", +] + +REDIS = { + "tasks": { + "HOST": "localhost", + "PORT": 6379, + "PASSWORD": "", + "DATABASE": 2, + "SSL": False, + }, + "caching": { + "HOST": "localhost", + "PORT": 6379, + "PASSWORD": "", + "DATABASE": 3, + "SSL": False, + }, +} + +RQ = { + "COMMIT_MODE": "auto", +} + +SECRET_KEY = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" +API_TOKEN_PEPPERS = { + 1: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", +} + +DEBUG_TOOLBAR_CONFIG = { + "IS_RUNNING_TESTS": False, +} diff --git a/netbox_dns/tests/test_netbox_dns.py b/netbox_dns/tests/test_netbox_dns.py index ee12ef68..8bb33e82 100644 --- a/netbox_dns/tests/test_netbox_dns.py +++ b/netbox_dns/tests/test_netbox_dns.py @@ -7,7 +7,7 @@ class NetBoxDNSVersionTestCase(SimpleTestCase): def test_version(self): - assert __version__ == "1.5.10" + assert __version__ == "1.6-beta1" class AppTest(APITestCase): diff --git a/pyproject.toml b/pyproject.toml index 7a606aa1..5e734aa0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "netbox-plugin-dns" -version = "1.5.10" +version = "1.6-beta1" description = "NetBox DNS is a NetBox plugin for managing DNS data." authors = [ {name="Peter Eckel", email="pe@sys4.de"}, @@ -11,7 +11,7 @@ license-files = [ readme = "README.md" keywords = ["netbox", "netbox-plugin", "dns"] classifiers = [ - "Development Status :: 5 - Production/Stable", + "Development Status :: 4 - Beta", "Framework :: Django", "Natural Language :: English", "Programming Language :: Python",