Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ This follows the standard [NetBox plugin pattern](https://netboxlabs.com/docs/ne
- **`models.py`**: Defines `InterfaceNameRule`. A rule can select an exact module type or a regex pattern, add parent, device, and platform scopes, and describe flat or channelized breakout output.
- **`signals.py`**: Connects `pre_save` and `post_save` for `dcim.Module`, `dcim.ModuleBay` and `dcim.Device` and passes each save to `rename_triggers.py` with the alias of the save, which must be the write alias. The receivers hold no state and make no decision. It intentionally does not connect to `dcim.Interface` because NetBox creates module interfaces with `bulk_create()`. It also connects the optional LibreNMS prediction signal when that plugin is installed.
- **`rename_triggers.py`**: Owns the rename-trigger lifecycle. It reads the previous state before a save and lets a read error fail the save. After the save it decides whether the save is a rename trigger and schedules one reapply per module or device per transaction with `transaction.on_commit()` on the connection of the save, so the plan runs after that connection commits. The reapply compares the earliest previous state with the committed row, and it catches and logs failures at that boundary.
- **`transactions.py`**: The one owner of database connections and transaction state. Each plugin write runs in a write scope on `default`, and in a netbox-branching branch also on the branch connection.
- **`branching.py`**: The one module that imports netbox-branching. It checks its version at startup, gives a job the identity of its branch, and marks each merge, revert and sync so that the rename triggers do nothing while it replays changes.
- **`rule_selection.py`**: Loads and fingerprints enabled rules, separates exact and regex candidates, applies scope priority, and pins one cached snapshot across batch work.
- **`name_template.py`**: Owns the name-template language, its template-variable catalogue, and evaluation.
- **`naming.py`**: Builds template-variable values from the module-bay hierarchy.
Expand Down
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ The complete path from a NetBox model save, through the committed callback, to t
_Avoid_: Signal handler performance

**Rename trigger**:
A saved change in NetBox after which the names a rule gives may be wrong, so the plugin must reapply its rules. The triggers are: a module is installed, a module's type changes, a module moves to another bay or device, an occupied module bay's position or name changes, and a device's virtual chassis or virtual-chassis position changes (the device joins a virtual chassis, leaves it, or gets a different position). A bay's name change is a trigger only when a template variable reads the name: a bay whose position is a template token takes its position from the trailing digits of its name. An edit of an empty bay is not a trigger. A module's type change also reaches each module nested in it whose rule changes, because a rule can be scoped to a parent module type. The triggers of one transaction cause one reapply plan, which reapplies each module and each device at most once. A device reapply also reapplies every module of its device that the plan did not already reapply for a module trigger.
A saved change in NetBox after which the names a rule gives may be wrong, so the plugin must reapply its rules. The triggers are: a module is installed, a module's type changes, a module moves to another bay or device, an occupied module bay's position or name changes, and a device's virtual chassis or virtual-chassis position changes (the device joins a virtual chassis, leaves it, or gets a different position). A bay's name change is a trigger only when a template variable reads the name: a bay whose position is a template token takes its position from the trailing digits of its name. An edit of an empty bay is not a trigger. A module's type change also reaches each module nested in it whose rule changes, because a rule can be scoped to a parent module type. A change that netbox-branching replays in a merge, a revert or a sync is not a rename trigger: the replayed changes already hold the names. The triggers of one transaction cause one reapply plan, which reapplies each module and each device at most once. A device reapply also reapplies every module of its device that the plan did not already reapply for a module trigger.
_Avoid_: Signal, event

**Reapply**:
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ automatically apply renaming rules based on configurable templates.
- **Breakout support**: create multiple channel interfaces from a single port (e.g., QSFP+ 4x10G)
- **Scoping**: rules can be scoped to specific device types, parent module types, or be universal
- **Bulk import/export**: YAML-based rule management via the UI or API
- **netbox-branching**: renames run in the active branch, and a merge, revert or sync keeps the names that it replays (netbox-branching 1.2.x on NetBox 4.7). A channel that the plugin kept at its old name is the exception: see [Limits in a branch](https://marcinpsk.github.io/netbox-InterfaceNameRules-plugin/configuration/#limits-in-a-branch)

## Supported scenarios

Expand Down
87 changes: 87 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,93 @@ The **Applicable** column shows ✓ only when at least one currently-installed
interface **would actually change name** if the rule were applied. Rules where
all matching interfaces are already correctly named show `—`.

## netbox-branching

The plugin supports [netbox-branching](https://github.com/netboxlabs/netbox-branching)
1.2.x on NetBox 4.7. When netbox-branching is installed, NetBox does not start
with another release of it, and the error names the installed version. Without
netbox-branching, the plugin supports NetBox 4.3 to 4.7 and works as this guide
describes.

### In a branch

While a branch is active, the plugin reads and writes in that branch only:

- A rename trigger in the branch renames the interfaces in the branch, after
NetBox commits the change. Each rename has a change record in the branch, so a
merge applies it to main.
- **Apply Rules** and the flat-to-channelized conversion change the interfaces
of the branch. A rule that exists only in the branch applies only there.
- **Run as Background Job** and **Convert as Background Job** run in the branch
that was active when you started the job. When that branch is not ready when
the job starts, for example because it was merged, the job fails and changes
nothing.
- A script or the shell must install a module in a transaction on the interface
write connection, as [Apply Rules and the Applicable Column](#apply-rules-and-the-applicable-column)
describes.

In a branch, each plugin operation sets the PostgreSQL `lock_timeout` to 10
seconds on the connection of the branch and on the connection of main. When the
operation ends, the plugin sets the earlier values again. A request in a branch
holds two PostgreSQL sessions, and PostgreSQL does not find a lock cycle through
the two sessions of one request. An operation that waits longer for a lock stops
with an error. A script can call an engine function, such as
`apply_device_interface_rules`, inside a transaction that the script holds. The
commit callbacks of the plugin then run when that transaction commits, with the
earlier `lock_timeout` of the session. Set `lock_timeout` for these callbacks in
your script.

### Merge, revert and sync

netbox-branching merges, reverts and syncs a branch: it replays the changes that
NetBox logged. The replayed changes already hold the interface names, so the
rename triggers do nothing while netbox-branching replays them.

- A merge gives main the interface names of the branch, except a kept channel
(see [Limits in a branch](#limits-in-a-branch)). On main, it writes only the
replayed changes.
- A revert of the merge gives main the names from before the merge.
- A sync gives the branch the names of main, except a kept channel, and writes no
rename without a change record. A rule that exists only in the branch does not rename the interfaces
that the sync brought. Run **Apply Rules** in the branch after the sync to
apply it.

After a merge, a revert or a sync that fails or stops early, for example a dry
run or a merge of a branch without changes, the next change is a rename trigger
again.

### Limits in a branch

- **A replay can rename a kept channel.** When the name that a rule gives a
channel subinterface is in use, the plugin keeps the old name of the channel
and renames its parent. NetBox renames the channels of a renamed parent when
the change commits, and the plugin then gives the kept channel its old name
again. A merge, a revert or a sync replays the rename of the parent, so
NetBox renames the channels again when the replay commits, and the plugin does
not act. After a merge, the kept channel on main then has the name from NetBox,
for example `et-0/0/1:2`, while the channel in the branch keeps `1:2`. After a
sync, the kept channel in the branch has the name from NetBox. A revert of the
merge gives the channel its name from before the merge. Rename such a channel
by hand when you want the name from the other side.
- **A background REST request runs on main.** NetBox runs a bulk REST request
with `background=true` as a background job, and that job does not keep the
active branch. The REST API of the rules refuses such a request while a branch
is active, before it writes. The other NetBox endpoints run it on main. For
example, modules that you install with such a request are installed on main,
and the plugin renames their interfaces on main.
- **A failed branch activation runs the request on main.** When NetBox cannot
activate the branch of a request, it continues the request on main. This
applies to all changes of the request, not only to the plugin.
- **No atomicity across the two connections.** netbox-branching records each
change of a branch on the connection of main. The plugin commits the connection
of the branch first and the connection of main second, as NetBox scripts and
netbox-branching do. When the second commit fails, or a NetBox callback fails
after the first commit, the branch keeps the renames, but the list of branch
changes in netbox-branching does not show them. A merge still applies them.
- **Connection pooling in transaction mode is not supported**, for example
PgBouncer with `pool_mode = transaction`. A session setting such as
`lock_timeout` does not stay with the session of such a pool.

## Bulk Import

Export existing rules or import new ones via **Interface Name Rules → Import**.
Expand Down
3 changes: 2 additions & 1 deletion docs/design/netbox-branching.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,8 @@ version check.
- Version gate: raise `ImproperlyConfigured` unless the installed netbox-branching is 1.2.x.
- Replay suppression: wrap `Branch.merge`, `Branch.revert` and `Branch.sync` once (idempotent,
`functools.wraps`). Each wrapper sets a ContextVar token and resets it in `finally`.
`replay_in_progress() -> bool`.
`replay_in_progress() -> bool`. `InterfaceNameRule.save()` skips its write-alias check while it is
true: a merge or revert started in an active branch replays a rule update on `default`.
- Job identity: `branch_identity() -> str | None` (the active branch's schema id) and
`activate_on(request, identity)`, which sets BR's branch cookie on a synthetic request.

Expand Down
4 changes: 4 additions & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,10 @@ Arista modular/multi-chassis naming uses `Ethernet{slot}/{port}`. The device typ
for dev in Device.objects.filter(virtual_chassis__isnull=False):
apply_device_interface_rules(dev)
```
In a netbox-branching branch, the function sets a `lock_timeout` of 10 seconds
while it runs. When you call it inside a transaction that you hold, its commit
callbacks run when your transaction commits, with the earlier `lock_timeout` of
the session. Set `lock_timeout` for these callbacks in your script.

---

Expand Down
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ automatically apply renaming rules based on configurable templates.
- **Scoping**: rules can target specific device types, parent module types, platforms, or be universal
- **Build Rule tester**: preview module and device-interface names before saving. Module rules also preview matching installed interfaces.
- **Apply Rules**: batch rename existing interfaces with live preview and background job support
- **netbox-branching**: renames run in the active branch, and a merge, revert or sync keeps the names that it replays (netbox-branching 1.2.x on NetBox 4.7). A channel that the plugin kept at its old name is the exception: see [Limits in a branch](configuration.md#limits-in-a-branch)

## Supported Scenarios

Expand Down
16 changes: 16 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

- NetBox ≥ 4.3.0
- Python ≥ 3.12
- Optional: netbox-branching 1.2.x, on NetBox 4.7. See
[netbox-branching](configuration.md#netbox-branching).

## Install from PyPI

Expand All @@ -19,6 +21,20 @@ Add to your NetBox `configuration.py`:
PLUGINS = ["netbox_interface_name_rules"]
```

## Before You Upgrade

Let the queued **Run as Background Job** and **Convert as Background Job** jobs
finish before you upgrade the plugin. A release can change the data that a job
stores, and a job that an earlier release queued then fails after the upgrade.
The release that adds netbox-branching support stores the branch of each job.
Start a failed job again after the upgrade.

With netbox-branching, a script can call an engine function, such as
`apply_device_interface_rules`, inside a transaction that the script holds. The
commit callbacks of the plugin then run when that transaction commits, with the
earlier `lock_timeout` of the session. Set `lock_timeout` for these callbacks in
your script. See [netbox-branching](configuration.md#netbox-branching).

## Run Database Migrations

The migration audits every existing nonempty **Module Type Pattern** used by a
Expand Down
4 changes: 2 additions & 2 deletions netbox_interface_name_rules/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ class InterfaceNameRulesConfig(PluginConfig):
author_email = "marcinpsk@gmail.com"

def ready(self):
"""Connect signal handlers after all apps are loaded, and check netbox-branching when it is installed."""
"""Connect signal handlers after all apps are loaded, and prepare for netbox-branching when it is installed."""
super().ready()
from . import branching, signals # signals registers the post_save handler

branching.check_installed_version()
branching.ready()


config = InterfaceNameRulesConfig
49 changes: 45 additions & 4 deletions netbox_interface_name_rules/branching.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,22 @@
# Copyright (C) 2025 Marcin Zieba <marcinpsk@gmail.com>
"""The one module that uses netbox-branching. It imports netbox-branching only in a function that needs it."""

import functools
from contextvars import ContextVar

from django.apps import apps
from django.core.exceptions import ImproperlyConfigured
from packaging.version import Version

APP_LABEL = "netbox_branching"
SUPPORTED_RELEASE = (1, 2)
SUPPORTED_SERIES = ".".join(map(str, SUPPORTED_RELEASE)) + ".x"
# The methods of netbox-branching's Branch that replay logged changes.
REPLAYING_METHODS = ("merge", "revert", "sync")
# The attribute that marks a method this module wrapped.
REPLAY_MARK = "marks_a_replay"

_replaying = ContextVar("netbox_interface_name_rules_replay", default=False)


def check_version(version: str) -> None:
Expand All @@ -19,10 +28,42 @@ def check_version(version: str) -> None:
)


def check_installed_version() -> None:
"""Check netbox-branching when NetBox starts, if it is installed."""
if apps.is_installed(APP_LABEL):
check_version(apps.get_app_config(APP_LABEL).version)
def installed_version() -> str:
"""Return the version of the installed netbox-branching."""
return apps.get_app_config(APP_LABEL).version


def ready() -> None:
"""When netbox-branching is installed, check its version and mark each of its replays in the context that runs it."""
if not apps.is_installed(APP_LABEL):
return
check_version(installed_version())
from netbox_branching.models import Branch

for name in REPLAYING_METHODS:
method = getattr(Branch, name)
if not getattr(method, REPLAY_MARK, False):
setattr(Branch, name, _marking_a_replay(method))


def _marking_a_replay(method):
"""Return *method*, wrapped so that the context that runs it is in a replay until it returns or raises."""

@functools.wraps(method)
def replay(*args, **kwargs):
token = _replaying.set(True)
try:
return method(*args, **kwargs)
finally:
_replaying.reset(token)

setattr(replay, REPLAY_MARK, True)
return replay


def replay_in_progress() -> bool:
"""Return whether a netbox-branching merge, revert or sync runs in this context."""
return _replaying.get()


def branch_identity() -> str | None:
Expand Down
6 changes: 4 additions & 2 deletions netbox_interface_name_rules/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from netbox.models import NetBoxModel
from taggit.managers import TaggableManager

from .branching import replay_in_progress
from .choices import BreakoutModeChoices
from .name_template import validate_rule
from .regex_safety import compile_module_type_pattern
Expand Down Expand Up @@ -347,6 +348,9 @@ class Meta:
def save(self, **kwargs):
"""Normalise the mode fields and validate topology and templates before a plain ORM write."""
using = kwargs.get("using") or router.db_for_write(self.__class__, instance=self)
# A merge started in an active branch replays its changes on default.
if not replay_in_progress() and using != (routed := router.db_for_write(self.__class__)):
raise RuntimeError(f"A rule save writes to {using!r}, but the router gives {routed!r} for a rule.")
update_fields = kwargs.get("update_fields")
if update_fields is not None:
# Django accepts any iterable. Reading a generator here would leave Django an empty
Expand All @@ -364,8 +368,6 @@ def save(self, **kwargs):
if written := _RULE_VALIDATION_FIELDS.intersection(update_fields):
# Validate the stored row on the alias Model.save() writes to, locked against a concurrent save.
kwargs["using"] = using
if using != (routed := router.db_for_write(self.__class__)):
raise RuntimeError(f"A rule save writes to {using!r}, but the router gives {routed!r} for a rule.")
with atomic_with_events() as block:
# netbox-branching routes an exempted rule model to default, which is an alias of every scope.
if using not in block.aliases:
Expand Down
13 changes: 11 additions & 2 deletions netbox_interface_name_rules/rename_triggers.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from django.db import transaction
from netbox.context import current_request

from .branching import replay_in_progress
from .naming import bay_naming_values, chassis_position
from .rename_outcomes import OutcomeKind, RenameOutcome, renamed_count
from .rule_selection import parent_type_scopes_a_rule
Expand Down Expand Up @@ -671,8 +672,11 @@ def _check_write_alias(using):
def before_save(sender, instance, using):
"""Read the previous state of *instance*, saved through *using*, and hold it for its post_save.

A read error propagates, and so does a save through another alias than the write alias.
A read error propagates, and so does a save through another alias than the write alias. A save that
netbox-branching replays returns before the write-alias check: a merge writes ``default`` while a branch is active.
"""
if replay_in_progress():
return
_check_write_alias(using)
read, _ = _TRIGGERS[sender._meta.label]
previous = None if instance.pk is None else read(instance)
Expand All @@ -687,7 +691,12 @@ def forget(reference):


def after_save(sender, instance, created, using):
"""Add the save of *instance* through *using* to the reapply plan of that connection when it is a rename trigger."""
"""Add the save of *instance* through *using* to the reapply plan of that connection when it is a rename trigger.

A save that netbox-branching replays returns before the write-alias check, as in ``before_save``.
"""
if replay_in_progress():
return
_check_write_alias(using)
_, trigger_of = _TRIGGERS[sender._meta.label]
# No entry: NetBox sent this post_save by hand, without a model save.
Expand Down
Loading
Loading