Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
37 changes: 19 additions & 18 deletions COMPATIBILITY.md
Original file line number Diff line number Diff line change
@@ -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+ |
57 changes: 56 additions & 1 deletion netbox_dns/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,67 @@
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):
if not isinstance(get_plugin_config("netbox_dns", setting), list):
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")
Expand Down Expand Up @@ -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"):
Expand Down
145 changes: 145 additions & 0 deletions netbox_dns/branching.py
Original file line number Diff line number Diff line change
@@ -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
Loading