diff --git a/.devcontainer/scripts/setup.sh b/.devcontainer/scripts/setup.sh index 2e9d8ff..df95b10 100755 --- a/.devcontainer/scripts/setup.sh +++ b/.devcontainer/scripts/setup.sh @@ -140,9 +140,9 @@ apt-get update -qq apt-get install -y -qq net-tools git # Dev tools used by the agent loop and pre-commit hooks. Keep in sync with # the `dev` extras in pyproject.toml — at minimum, anything invoked by: -# - test + coverage runs: pytest, pytest-django, pytest-cov +# - test + coverage runs: pytest, pytest-django, pytest-cov, pytest-xdist # - pre-commit hooks (.pre-commit-config.yaml): ruff, pre-commit, reuse -$PIP_CMD install pytest pytest-django pytest-cov ruff pre-commit reuse +$PIP_CMD install pytest pytest-django pytest-cov pytest-xdist ruff pre-commit reuse # Install GitHub CLI if ! command -v gh >/dev/null 2>&1; then diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c5175ee..a0ab0bb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,24 +2,22 @@ ## Project overview -NetBox plugin that automatically renames interfaces when modules (transceivers, line cards, converters) are installed into device module bays. It hooks into Django's `post_save` signal on `dcim.Module` to apply configurable renaming rules with template variable substitution and arithmetic expression support. +NetBox plugin that automatically renames interfaces when modules (transceivers, line cards, converters) are installed into device module bays. It also reapplies rules when a module type or a device's virtual-chassis position changes. Django signals defer the work until the surrounding transaction commits. -Requires NetBox ≥ 4.2.0 and Python ≥ 3.12. Licensed under Apache-2.0 (REUSE-compliant). +Requires NetBox ≥ 4.3.0 and Python ≥ 3.12. Licensed under Apache-2.0 (REUSE-compliant). ## Architecture This follows the standard [NetBox plugin pattern](https://netboxlabs.com/docs/netbox/en/stable/plugins/development/): -- **`models.py`** — Single model `InterfaceNameRule` linking a module type (nullable FK, required only in exact mode) to a name template, with optional scoping to parent module type, device type, and/or platform. Rules are matched most-specific-first. -- **`signals.py`** — Two signal handlers: - - `post_save` on `dcim.Module` (primary path): fires on `created=True`, defers renaming to `on_commit` so interfaces exist in DB first. This is the **only** path that runs during normal module installation because NetBox creates interfaces via `bulk_create()`. - - `pre_save` on `dcim.Interface` (defence-in-depth): would rename before INSERT, but **does NOT fire** during normal module installation because `bulk_create()` skips `pre_save` signals. Only fires when interfaces are created individually via `Interface.save()` (scripts, custom code, etc.). - - Lazily imports the engine to avoid circular imports during Django startup. -- **`engine.py`** — Core logic: two-tier rule lookup (`_find_matching_rule` first tries an exact FK match across 4 specificity levels, then falls back to regex matching with `re.fullmatch()` across the same 4 levels), template variable building from module bay hierarchy, and interface renaming/breakout creation. The `evaluate_name_template` function supports `{variable}` substitution followed by safe AST-based arithmetic evaluation of remaining brace expressions. -- **`api/`** — DRF REST API using NetBox's `NetBoxModelViewSet` and `NetBoxModelSerializer`. -- **`views.py`, `urls.py`, `tables.py`, `forms.py`, `filters.py`, `navigation.py`** — Standard NetBox UI CRUD views. -- **`utils.py`** — Feature detection for gating (e.g., `{module_path}` token support detected via `dcim.constants.MODULE_PATH_TOKEN` import). +- **`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`**: Handles `pre_save` and `post_save` for `dcim.Module` and `dcim.Device`. It records prior state, schedules work with `transaction.on_commit()`, and catches failures at the deferred callback boundary. 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. +- **`rule_selection.py`**: Loads and fingerprints enabled rules, separates exact and regex candidates, applies scope priority, and pins one cached snapshot across batch work. +- **`naming.py`**: Builds variables from the module-bay hierarchy and evaluates templates. It replaces known variables, parses the remaining integer arithmetic, and evaluates only supported AST nodes. +- **`family/`**: Owns the interface-family domain model, discovery, planning, execution, structural creation, conversion, name collision checks, and NetBox capability detection. +- **`engine.py`**: Orchestrates rule application, prediction, virtual-chassis reapply, preview, and batch operations. It keeps stable entry points while delegating rule selection, naming, and family behavior to their owning modules. +- **`api/` and `graphql/`**: Expose NetBox REST and GraphQL integrations. +- **`views.py`, `urls.py`, `tables.py`, `forms.py`, `filters.py`, `navigation.py`, `jobs.py`**: Provide NetBox UI and background-job integrations. The signal handler → engine import is intentionally lazy to ensure Django models are fully loaded before use. @@ -89,9 +87,9 @@ The `reuse-lint` pre-commit hook validates compliance on every commit. ## Key conventions - All views, forms, serializers, and tables inherit from NetBox's base classes (`NetBoxModel`, `NetBoxModelViewSet`, `NetBoxModelForm`, etc.) — always use these, not raw Django/DRF equivalents. Non-model forms are the exception: NetBox 4.x dropped `BootstrapMixin` and styles every form through its own widget templates (`FORM_RENDERER = TemplatesSetting`), so a plain form subclasses `django.forms.Form`, exactly as NetBox's own `ConfirmationForm`/`BulkRenameForm` do. -- Template variables use Python `str.format()` syntax: `{slot}`, `{bay_position}`, `{bay_position_num}`, `{parent_bay_position}`, `{sfp_slot}`, `{base}`, `{channel}`, `{module_path}` (gated via `utils.supports_module_path()` using import-based feature detection). -- Arithmetic inside braces is evaluated via `ast.parse` with a strict allowlist of AST node types — never use `eval()` directly on user input. +- Template variables use braces: `{slot}`, `{bay_position}`, `{bay_position_num}`, `{parent_bay_position}`, `{sfp_slot}`, `{base}`, `{channel}`, and `{vc_position}`. `naming.py` replaces known variables explicitly before it parses arithmetic. +- Arithmetic inside braces is parsed with `ast.parse` and evaluated recursively for the supported integer operators. Never use `eval()` on user input. - The `tags` field on `InterfaceNameRule` uses `related_name="+"` to avoid reverse accessor clashes with other plugins. -- Rule matching uses **two tiers** within each priority level — exact FK match first, then regex (`re.fullmatch()`) fallback. Priority levels (applied in both tiers): (module_type + parent + device) → (module_type + parent) → (module_type + device) → (module_type only). +- Rule matching uses two tiers. Exact module-type rules take priority over regex rules. Within each tier, `rule_selection.py` applies the parent, device, and platform scope score, then the documented tie breakers. - Add new rules to the appropriate vendor-specific file under `contrib/` (`cisco.yaml`, `juniper.yaml`, `linux.yaml`, `ufispace.yaml`, `ufispace-device-type.yaml`, `converters.yaml`) — keep them updated when adding new rule patterns. - Commits follow [Conventional Commits](https://www.conventionalcommits.org/) format, enforced by pre-commit hook. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 043a1d8..ba3d962 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,11 +11,15 @@ updates: directory: "/" # Location of package manifests schedule: interval: "weekly" + cooldown: + default-days: 7 - package-ecosystem: "github-actions" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly" + cooldown: + default-days: 7 groups: github-actions: patterns: @@ -25,8 +29,12 @@ updates: directory: "/.devcontainer" # Location of devcontainer.json schedule: interval: "weekly" + cooldown: + default-days: 7 - package-ecosystem: "docker-compose" # See documentation for possible values directory: "/.devcontainer" # Location of docker-compose.yml schedule: interval: "weekly" + cooldown: + default-days: 7 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d92a765..a9e3088 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -63,6 +63,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # Add any setup steps before running the `github/codeql-action/init` action. # This includes steps like installing compilers or runtimes (`actions/setup-node` diff --git a/.github/workflows/coverage-badge.yaml b/.github/workflows/coverage-badge.yaml index 5d669bd..6e1df17 100644 --- a/.github/workflows/coverage-badge.yaml +++ b/.github/workflows/coverage-badge.yaml @@ -3,6 +3,8 @@ name: Update coverage report on: + # zizmor: ignore[dangerous-triggers] the job accepts only a successful push from this + # repository's main branch, and never consumes artifacts from pull-request code. workflow_run: workflows: ["Test with supported NetBox and Python versions"] types: [completed] @@ -66,6 +68,8 @@ jobs: fi - name: Checkout gh-pages + # zizmor: ignore[artipacked] this job pushes the coverage report to gh-pages, + # so this checkout has to keep its credential. uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: gh-pages diff --git a/.github/workflows/lint-format.yaml b/.github/workflows/lint-format.yaml index 2858744..a01c64d 100644 --- a/.github/workflows/lint-format.yaml +++ b/.github/workflows/lint-format.yaml @@ -9,9 +9,13 @@ on: jobs: format-and-lint: runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 @@ -22,7 +26,7 @@ jobs: python-version: '3.12' - name: Install dependencies - run: uv pip install --system ruff + run: 'uv pip install --system --only-binary=:all: pre-commit==4.5.1 ruff==0.16.0' - name: Run Ruff linting run: ruff check . @@ -30,5 +34,9 @@ jobs: - name: Run Ruff formatting check run: ruff format --check . + # Runs the pinned hook from .pre-commit-config.yaml, so the version lives in one place. + - name: Audit the workflows + run: pre-commit run --all-files zizmor + - name: Run devcontainer script tests run: bash .devcontainer/scripts/tests/test-debug-toolbar-patches.sh diff --git a/.github/workflows/mkdocs.yaml b/.github/workflows/mkdocs.yaml index a24d5ac..a832057 100644 --- a/.github/workflows/mkdocs.yaml +++ b/.github/workflows/mkdocs.yaml @@ -26,6 +26,8 @@ jobs: contents: write steps: - name: Checkout repository + # zizmor: ignore[artipacked] mkdocs gh-deploy pushes to gh-pages, so this + # checkout has to keep its credential. uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 diff --git a/.github/workflows/pr-title.yaml b/.github/workflows/pr-title.yaml index 04263dc..39dbd9a 100644 --- a/.github/workflows/pr-title.yaml +++ b/.github/workflows/pr-title.yaml @@ -3,7 +3,7 @@ name: Validate PR title on: - pull_request_target: + pull_request: types: [opened, edited, synchronize] permissions: diff --git a/.github/workflows/publish-pypi.yaml b/.github/workflows/publish-pypi.yaml index 64c77d5..d7dc4b7 100644 --- a/.github/workflows/publish-pypi.yaml +++ b/.github/workflows/publish-pypi.yaml @@ -18,13 +18,18 @@ jobs: build: name: Build distribution 📦 runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.tag || github.ref }} + persist-credentials: false - name: Install uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + enable-cache: false - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 5d43e8c..588040c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -17,6 +17,8 @@ jobs: pull-requests: write steps: - name: Checkout + # zizmor: ignore[artipacked] semantic-release pushes the release commit and tag, + # so this checkout has to keep its credential. uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 diff --git a/.github/workflows/test-netbox-main.yaml b/.github/workflows/test-netbox-main.yaml index 85994ac..a7a0fb2 100644 --- a/.github/workflows/test-netbox-main.yaml +++ b/.github/workflows/test-netbox-main.yaml @@ -14,6 +14,8 @@ concurrency: jobs: test-netbox-main: runs-on: ubuntu-latest + permissions: + contents: read strategy: fail-fast: false @@ -49,6 +51,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: path: netbox-InterfaceNameRules-plugin + persist-credentials: false - name: Install uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 @@ -64,12 +67,13 @@ jobs: repository: "netbox-community/netbox" path: netbox ref: main + persist-credentials: false - name: Install NetBox and plugin working-directory: netbox-InterfaceNameRules-plugin run: | uv pip install --system -r ../netbox/requirements.txt - uv pip install --system pytest pytest-django tblib + uv pip install --system --only-binary=:all: pytest==9.0.2 pytest-django==4.12.0 pytest-xdist==3.8.0 tblib==3.2.2 uv pip install --system -e . - name: Set up NetBox configuration @@ -96,8 +100,9 @@ jobs: EOF - name: Run tests - working-directory: netbox/netbox + working-directory: netbox-InterfaceNameRules-plugin env: NETBOX_CONFIGURATION: netbox.configuration + PYTHONPATH: ${{ github.workspace }}/netbox/netbox run: | - python manage.py test netbox_interface_name_rules --verbosity=2 + pytest -n auto netbox_interface_name_rules -o pythonpath=../netbox/netbox diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 3c3417f..f6d7173 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -18,6 +18,8 @@ concurrency: jobs: test-netbox: runs-on: ubuntu-latest + permissions: + contents: read strategy: fail-fast: false @@ -66,6 +68,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: path: netbox-InterfaceNameRules-plugin + persist-credentials: false - name: Install uv uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 @@ -81,12 +84,13 @@ jobs: repository: "netbox-community/netbox" path: netbox ref: ${{ matrix.netbox-version }} + persist-credentials: false - name: Install NetBox and plugin working-directory: netbox-InterfaceNameRules-plugin run: | uv pip install --system -r ../netbox/requirements.txt - uv pip install --system pytest pytest-django tblib coverage + uv pip install --system --only-binary=:all: pytest==9.0.2 pytest-cov==7.0.0 pytest-django==4.12.0 pytest-xdist==3.8.0 tblib==3.2.2 uv pip install --system -e . - name: Set up NetBox configuration @@ -113,10 +117,11 @@ jobs: EOF - name: Run tests with coverage - working-directory: netbox/netbox + working-directory: netbox-InterfaceNameRules-plugin env: NETBOX_CONFIGURATION: netbox.configuration - COVERAGE_RCFILE: ../../netbox-InterfaceNameRules-plugin/pyproject.toml + PYTHONPATH: ${{ github.workspace }}/netbox/netbox + COVERAGE_RCFILE: pyproject.toml # Turns the channelization tests' skipUnless guard into an assertion on the leg that must # have the feature, so a broken probe cannot silently skip the whole file. EXPECT_NETBOX_CHANNELIZATION: ${{ matrix.netbox-version == 'feature' && '1' || '' }} @@ -124,19 +129,19 @@ jobs: # checkout so these cells report plugin compatibility failures instead of baseline drift. UPDATE_QUERY_COUNTS: ${{ matrix.experimental && '1' || '' }} run: | - coverage run manage.py test netbox_interface_name_rules --verbosity=2 - coverage report + pytest -n auto netbox_interface_name_rules --cov=netbox_interface_name_rules --cov-report=term-missing \ + -o pythonpath=../netbox/netbox - name: Generate coverage report if: matrix.python-version == '3.12' && matrix.netbox-version == 'v4.5.3' - working-directory: netbox/netbox + working-directory: netbox-InterfaceNameRules-plugin env: - COVERAGE_RCFILE: ../../netbox-InterfaceNameRules-plugin/pyproject.toml + COVERAGE_RCFILE: pyproject.toml run: | - mkdir -p ../../coverage-report - coverage json -o ../../coverage-report/coverage.json - coverage html -d ../../coverage-report/htmlcov - coverage xml -o ../../coverage-report/coverage.xml + mkdir -p ../coverage-report + coverage json -o ../coverage-report/coverage.json + coverage html -d ../coverage-report/htmlcov + coverage xml -o ../coverage-report/coverage.xml - name: Upload coverage report if: matrix.python-version == '3.12' && matrix.netbox-version == 'v4.5.3' diff --git a/.gitignore b/.gitignore index 8452b5f..6451156 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ venv/ .coverage.* htmlcov/ coverage.xml +performance/baselines/*.json # Ruff / linting .ruff_cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 543128c..babd209 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,6 +20,12 @@ repos: - id: mixed-line-ending args: [--fix=lf] + # GitHub Actions security audit. The lint workflow runs this same hook. + - repo: https://github.com/zizmorcore/zizmor-pre-commit + rev: v1.29.0 + hooks: + - id: zizmor + # Python code quality - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.16.0 diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..c3495de --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,90 @@ + + +# Interface Name Rules + +This context describes naming rules and the related interface topologies they manage. + +## Language + +**Interface family**: +A set of related interfaces that represents one physical port and its breakout channels in either a flat or channelized topology. +_Avoid_: Channel group, breakout set + +**Flat breakout family**: +An interface family whose channels are sibling interfaces without a physical parent relationship. +_Avoid_: Flat channels + +**Channelized family**: +An interface family with one physical parent that declares channel capacity and zero or more channel interfaces bound to it. The family remains channelized when it is incomplete. +_Avoid_: Parented breakout + +**Installed interface family**: +An interface family represented by the current NetBox interface rows. +_Avoid_: Persisted family + +**Prospective interface family**: +An intended interface family described before its interface rows exist. +_Avoid_: Predicted family + +**Family plan**: +An immutable description of one intended interface-family operation, its required live state, and its expected member outcomes. +_Avoid_: Rename instructions + +**Installed family plan**: +A family plan that includes a validated snapshot of installed NetBox rows and can be executed after live-state revalidation. +_Avoid_: Executable prediction + +**Family plan set**: +An immutable batch that contains exactly one family plan for each interface family in an operation. +_Avoid_: Plan list + +**Family rename**: +A change to the names of existing interface-family members that does not change their topology. +_Avoid_: Family conversion + +**Structural family change**: +A change that creates an interface family or changes it between flat and channelized topologies. +_Avoid_: Family rename + +**Flat-to-channelized conversion**: +A structural family change that rebuilds one installed flat breakout family as a channelized family, keeping the physical interface row and moving its logical identity onto the channel that takes its name. It is always an explicit operator action, never a side effect of applying a rule. +_Avoid_: Migration, upgrade + +**Out-of-band rename**: +A change to an interface name made by an actor other than this plugin, such as an operator edit or an import. A family plan is stale when one arrives between planning and execution. +_Avoid_: External rename, manual fix + +**Engine facade**: +The compatibility surface downstream callers import. It selects rules, builds template variables and decides which interfaces an automatic path may touch on a run; it holds no family discovery, planning or mutation. +_Avoid_: Engine layer, core + +**Stored rule pattern**: +An operator-provided RE2 expression saved on an Interface Name Rule. It matches the complete module type model or current device-interface name, depending on the rule mode. +_Avoid_: Python regex, partial regex + +**Unsupported topology**: +An interface-family topology that the active NetBox data model cannot represent. +_Avoid_: Legacy fallback + +**Blocked family operation**: +A valid requested family change that current device state prevents, such as when a required name is already occupied. +_Avoid_: Failed family operation + +**Automatic naming signal path**: +The complete path from a NetBox model save, through the committed callback, to the resulting interface-family rows. +_Avoid_: Signal handler performance + +**Signal-path performance baseline**: +Test-suite measurements of the automatic naming signal path before an implementation change. The baseline includes query counts, PostgreSQL work profiles, scaling behavior, and repeated machine-time samples collected on the hardware used for the after measurement. Shared-runner elapsed time is not a recurring CI metric. +_Avoid_: Runtime limit, CI speed + +**PostgreSQL work profile**: +A test-suite record of the database work performed by one automatic naming scenario. It includes normalized statements, execution plans, rows, loops, buffer access, temporary storage, and WAL activity. +_Avoid_: Standalone query benchmark + +**Implementation performance comparison**: +A one-time comparison produced by running the same test-suite performance scenarios before and after an implementation change on the same hardware. It includes database work, uninstrumented wall time, process CPU time, and raw timing samples. It is review evidence, not a recurring CI gate. +_Avoid_: Performance CI diff --git a/REUSE.toml b/REUSE.toml index 4772834..f48bb26 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -24,6 +24,7 @@ path = [ "README.md", "CONTRIBUTING.md", "docs/**", + "performance/**", ] SPDX-FileCopyrightText = "2025 Marcin Zieba " SPDX-License-Identifier = "Apache-2.0" diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..e12d921 --- /dev/null +++ b/conftest.py @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2025 Marcin Zieba +"""Root pytest configuration for shared-host parallelism.""" + +MAX_PARALLEL_WORKERS = 8 + + +def pytest_xdist_auto_num_workers(config): + """Cap the worker count that pytest-xdist detects for ``-n auto``.""" + from xdist.plugin import pytest_xdist_auto_num_workers as detected_num_workers + + return min(detected_num_workers(config), MAX_PARALLEL_WORKERS) diff --git a/docs/adr/0001-interface-family-operation-atomicity.md b/docs/adr/0001-interface-family-operation-atomicity.md new file mode 100644 index 0000000..f057f85 --- /dev/null +++ b/docs/adr/0001-interface-family-operation-atomicity.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Separate structural atomicity from partial rename handling + +Each family operation owns one database transaction, which isolates it from other families and rolls back unexpected failures. Structural creation and conversion commit or roll back the complete topology together. An installed-family rename uses nested savepoints for expected member-level validation and name collisions: the parent must succeed first, but a blocked channel leaves only that channel unchanged while successful channel renames commit. This preserves useful names without permitting a partial structural topology. diff --git a/docs/adr/0002-revalidate-family-plans-before-execution.md b/docs/adr/0002-revalidate-family-plans-before-execution.md new file mode 100644 index 0000000..507fd74 --- /dev/null +++ b/docs/adr/0002-revalidate-family-plans-before-execution.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Revalidate family plans before execution + +An interface-family plan is never trusted across a change to its interfaces. Execution revalidates identity, names, membership, and topology against live rows, then rejects a stale plan instead of silently constructing a replacement or applying only the members that still match. Interactive apply constructs a fresh plan from live rows rather than executing the earlier preview. This keeps the executed change equal to the selected plan and prevents concurrent edits from being overwritten. diff --git a/docs/adr/0003-profile-database-work-on-the-signal-path.md b/docs/adr/0003-profile-database-work-on-the-signal-path.md new file mode 100644 index 0000000..41dbc85 --- /dev/null +++ b/docs/adr/0003-profile-database-work-on-the-signal-path.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Profile database work on the signal path + +The test suite records a PostgreSQL work profile for each representative automatic naming scenario before the interface-family refactor begins. The profile comes from the real Django signal path and records statement counts, normalized SQL, execution plans, actual rows and loops, buffer access, temporary storage, WAL activity, and scaling behavior. PostgreSQL collects the execution data with node timing disabled. The test suite also runs a separate uninstrumented timing pass and records raw samples, wall time, and process CPU time. Run both passes before the refactor and rerun them afterward on the same hardware with the same PostgreSQL version, NetBox revision, fixtures, planner settings, and statistics. Retain the result as review evidence. CI does not use the machine-time comparison as a recurring regression gate. diff --git a/docs/adr/0004-use-immutable-family-plan-boundaries.md b/docs/adr/0004-use-immutable-family-plan-boundaries.md new file mode 100644 index 0000000..0419804 --- /dev/null +++ b/docs/adr/0004-use-immutable-family-plan-boundaries.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Use immutable family-plan boundaries + +The interface-family module exposes immutable installed and prospective plan sets. Each set contains exactly one plan per family. The installed adapter owns bulk ORM loading, family discovery, and row snapshots. The prospective adapter owns rowless template inputs. Callers provide semantic operation roots instead of querysets or discovered members. Only plans that carry live row snapshots can execute, and execution returns explicit family and member outcomes. Existing engine entry points remain thin adapters to this boundary. diff --git a/docs/adr/0005-execute-each-family-in-its-own-transaction.md b/docs/adr/0005-execute-each-family-in-its-own-transaction.md new file mode 100644 index 0000000..dfcf112 --- /dev/null +++ b/docs/adr/0005-execute-each-family-in-its-own-transaction.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Execute each family in its own transaction + +Each installed family executes in its own database transaction. Execution locks member rows in stable primary-key order and revalidates the plan after locking. Structural changes roll back the complete family. Existing-family renames use child savepoints after the parent succeeds, so one blocked child does not undo unrelated member renames. A blocked family does not roll back other families in the plan set. Target-name checks run after locking, and NetBox's device-and-name uniqueness constraint remains the final collision guard without a device-wide interface lock. diff --git a/docs/adr/0006-make-engine-a-family-facade.md b/docs/adr/0006-make-engine-a-family-facade.md new file mode 100644 index 0000000..5551ce2 --- /dev/null +++ b/docs/adr/0006-make-engine-a-family-facade.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Make the engine a family facade + +The existing engine entry points remain as compatibility adapters. Shared rule selection, variable construction, and template evaluation move into lower-level modules. A dedicated family package owns immutable domain values, installed and prospective adapters, planning, revalidation, locking, execution, conversion, and deferred reconciliation. The family package never imports the engine facade. This dependency direction prevents circular imports and removes the old private family implementation when the replacement is complete. diff --git a/docs/adr/0007-report-an-unsupported-topology-as-a-family-outcome.md b/docs/adr/0007-report-an-unsupported-topology-as-a-family-outcome.md new file mode 100644 index 0000000..745796d --- /dev/null +++ b/docs/adr/0007-report-an-unsupported-topology-as-a-family-outcome.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Report an unsupported topology as a family outcome + +The family package owns the probe that decides whether the active NetBox data model can hold a channelized family, and it probes the Interface model rather than comparing versions. A rule that describes a topology the release cannot hold produces a plan whose precondition is `unsupported` and an outcome that says so. Callers map that outcome to their own reporting; they never branch on the NetBox version and never fall back to a different topology. A caller-side version check would let one entry point build a flat family that another refuses, and silently give the operator a topology the rule did not ask for. diff --git a/docs/adr/0008-plan-prediction-and-preview-prospectively.md b/docs/adr/0008-plan-prediction-and-preview-prospectively.md new file mode 100644 index 0000000..a5203b7 --- /dev/null +++ b/docs/adr/0008-plan-prediction-and-preview-prospectively.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Plan prediction and preview prospectively + +Rowless prediction and the interactive preview build prospective family plans through the same naming rules installed execution follows. One shared target module names every family, so an installed plan, a prospective plan and a structural plan cannot spell the same family differently. A prospective plan carries names rather than row snapshots and the executors reject one by type, so a preview can never become an executable snapshot and prediction can describe a family NetBox has not created yet. Interactive apply ignores the preview object and plans again from live rows, so a change between the two is replanned rather than written over. A plan the live topology blocks previews and predicts no change, because the apply path would build nothing there either. diff --git a/docs/adr/0009-apply-a-rule-batch-family-by-family.md b/docs/adr/0009-apply-a-rule-batch-family-by-family.md new file mode 100644 index 0000000..f64b241 --- /dev/null +++ b/docs/adr/0009-apply-a-rule-batch-family-by-family.md @@ -0,0 +1,13 @@ +--- +status: accepted +--- + +# Apply a rule batch family by family + +Retroactive apply plans every family a rule intends on each module and executes each family on its own. A module's installed families claim their members first; what is left over is planned as the family the rule would build there, so no interface belongs to two plans and two bases that intend one family's names build it once. Execution returns one explicit family result per family, and the Apply view and the background job read their counts and their skips from those results instead of a mutable conflict list. A family that is blocked, stale or unnamable costs the batch only itself: the batch keeps planning and executing the families after it. + +One batch shares what its modules share. The module rows come with the relations template resolution dereferences, and one module type's interface templates are read once however many modules carry it. Retroactive apply also reads every module's interfaces in one query. Virtual-chassis reapplication is a batch of the same kind, but it reaches the families through the installation entry point, so it still reads one module's interfaces at a time and still renames a leftover interface outside the family executor. Contracting that entry point onto the same batch was the remaining step; ADR 0011 completes it. + +Every rename retroactive apply performs goes through the locked, revalidated family executor, which costs a savepoint pair and a row lock per family. That price buys stale-plan rejection and per-family isolation on a path that had neither. + +A module's plain interface count decides whether an earlier flat breakout already expanded it, because converting one sibling into a family parent would strand the others. A channel belongs to the parent that declares it, so it never counts toward that surplus: counting one would stop a module's second port from gaining a family of its own. diff --git a/docs/adr/0010-convert-flat-families-through-conversion-plans.md b/docs/adr/0010-convert-flat-families-through-conversion-plans.md new file mode 100644 index 0000000..964d136 --- /dev/null +++ b/docs/adr/0010-convert-flat-families-through-conversion-plans.md @@ -0,0 +1,15 @@ +--- +status: accepted +--- + +# Convert flat families through conversion plans + +The family package plans and executes one immutable flat-to-channelized conversion plan per family, beside the planners that install and rename families. A plan carries the row snapshots the ch-0 split needs and nothing else: the base row that becomes the parent, the siblings that become channels 2..N, and the parent name the rule resolves. Execution locks the planned rows, compares them against those snapshots, and refuses a family whose identity, names, membership or topology moved since the scan. The scan itself performs each conversion inside a savepoint it always rolls back, so a candidate reports the reason NetBox would refuse the family rather than a guess at its rules. + +Conversion recovers its families through the base names the rename path recovers, so the two cannot drift on which rows belong to which family, a family named before a virtual-chassis renumber included. It identifies a family by its ch-0 row, the way the flat apply that installed it named that row, and then takes whatever the module still carries for the rest of the family. A family with a gap is offered and refused, naming the row it is missing, because dropping it from the page would read as "nothing here to convert" and hide an edit the operator needs to see. A sibling that already belongs to another parent is refused the same way rather than taken from that family. The parent takes the name the rule resolves for the module now, which is the name an apply would give it; the channels keep the names they carry, because converting retypes those rows in place. + +A family the plan can already refuse carries the refusal as a plan precondition, so the scan reports it without a dry run and execution rejects it without locking a row. Everything a snapshot cannot settle stays in the locked preflight beside NetBox's own validation. + +Conversion returns one explicit family outcome per family. The Apply view and the background job read their converted and skipped counts from those outcomes, so no conversion tuple and no mutable conflict list crosses the boundary. The candidate an operator confirms carries its names, roles and blocking reason from the plan; it keeps the live module and ch-0 rows only so the page can link to them. + +The addresses and FHRP group assignments on the ch-0 row move onto the new channel through model saves, one row at a time. A queryset update relocated them with no validation, no signal and no changelog entry, so a device's history showed the family rewritten and the objects on it moved by nobody. They are objects an operator owns and audits, and they are now written to the standard the rest of the family write already meets. diff --git a/docs/adr/0011-contract-the-engine-to-a-family-facade.md b/docs/adr/0011-contract-the-engine-to-a-family-facade.md new file mode 100644 index 0000000..f798ae0 --- /dev/null +++ b/docs/adr/0011-contract-the-engine-to-a-family-facade.md @@ -0,0 +1,13 @@ +--- +status: accepted +--- + +# Contract the engine to a family facade + +The engine no longer holds a family implementation. Automatic installation, prediction, interactive preview and apply, bulk operations, virtual-chassis reapplication, device-level renaming, conversion and deferred reconciliation all reach the family package, and every rename, creation and rewrite goes through one locked, revalidating executor. This completes the direction set in ADR 0006 and the remaining step named in ADR 0009. + +What stays in the engine is what was never about families: which rule wins, how a rule's variables are built, and the raw-name idempotency guard that decides which interfaces an automatic path may touch on this run. The guard runs while it can still see every interface a rule claimed, before two of them that intend one family are collapsed into it, because an ambiguous pair is only visible while both are present. The device-level path keeps its own rule selection and its claim bookkeeping for the same reason, and asks the package for lockstep names directly: a device rule never builds a family, so its channel count says nothing about one it finds. + +Module installation now reads a module's interfaces once and plans the whole module, rather than planning installed families, executing them, and then re-reading what was left. Device-level renaming gained revalidation and row locking it never had. + +The private helpers the engine used are deleted rather than wrapped, so no second implementation can drift from the package. The family package does not import the engine facade, and the shared dependencies point only at rule selection and naming. diff --git a/docs/adr/0012-execute-stored-rule-patterns-with-re2.md b/docs/adr/0012-execute-stored-rule-patterns-with-re2.md new file mode 100644 index 0000000..846adad --- /dev/null +++ b/docs/adr/0012-execute-stored-rule-patterns-with-re2.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# Execute stored rule patterns with RE2 + +Stored rule patterns are operator input that can execute on the automatic naming signal path, so the plugin compiles and executes them only with RE2 and never falls back to Python `re`. This deliberately rejects lookaround, backreferences, atomic groups and other Python-only syntax in exchange for linear matching time and bounded memory use. An upgrade migration audits existing rows for syntax and Unicode semantic differences before they can run under the new engine. Fixed internal expressions remain on Python `re` because they are not stored rule patterns. diff --git a/docs/configuration.md b/docs/configuration.md index 205828d..027193a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,7 +9,7 @@ Navigate to **Plugins → Interface Name Rules → Add** or use the REST API. | Field | Required | Description | |-------|----------|-------------| | Module Type | Conditional | The module type that triggers this rule (required when Regex Mode is off) | -| Module Type Pattern | Conditional | Regex pattern matched against module type model name via `re.fullmatch()` (required when Regex Mode is on) | +| Module Type Pattern | Conditional | RE2 pattern matched against the complete module type model name (required when Regex Mode is on) | | Regex Mode | No | When enabled, match by pattern instead of exact module type FK | | Parent Module Type | No | Restrict to modules inside this parent (e.g., converter) | | Device Type | No | Restrict to devices of this type | @@ -28,7 +28,17 @@ When multiple rules could match, the most specific one wins. Exact FK matches al 4. Module type only (universal) **Tier 2 — Regex pattern match (fallback, longest pattern first):** -5–8. Same four specificity levels, but `module_type_pattern` is matched via `re.fullmatch()` against the installed module type's model name. When multiple patterns match at the same level, the longest pattern is preferred. +5–8. Same four specificity levels, but `module_type_pattern` is matched against the complete installed module type model name. When multiple patterns match at the same level, the longest pattern is preferred. + +### RE2 Pattern Syntax + +The plugin compiles and executes every stored rule pattern with +[RE2](https://github.com/google/re2/wiki/syntax). RE2 guarantees bounded memory +use and linear matching time. It does not support Python-only features that +require backtracking, including lookaround, backreferences, and atomic groups. +Use `\z` instead of Python's `\Z` end-of-text escape. The `\d`, `\s`, and `\w` +classes match ASCII characters. Use an RE2 Unicode property such as `\p{L}` when +the rule must match Unicode letters. ## NetBox Module Interface Templates diff --git a/docs/examples.md b/docs/examples.md index adb1a07..863f82b 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -176,22 +176,24 @@ disabled rule converts nothing, on this page or in the background job. Conversion is only ever performed from that page, by an operator who confirmed it: **Apply** renames, it never rewrites a family. Each family gets its own -verdict before anything is written, produced by performing the whole conversion -inside a transaction that is rolled back again — so a family that NetBox would -reject is reported with NetBox's own reason (a cabled sibling, an occupied -parent name, a missing sibling, a sibling already channelized, a sibling that is -already a channel of another parent) instead of being half converted. Selecting +verdict before anything is written. A family the plugin can already refuse from +what it knows, such as a module that no longer carries the whole family, is reported +without touching a row; every other verdict comes from performing the whole +conversion inside a transaction that is rolled back again, so a family NetBox +would reject is reported with NetBox's own reason (a cabled sibling, an occupied +parent name, a sibling already channelized, a sibling that is already a channel +of another parent) instead of being half converted. Selecting a blocked family converts the others and skips that one. What the conversion does to the ch-0 row, per family: | Stays on the physical row (same interface ID) | Moves to the new channel 1 interface | |---|---| -| cable, interface type, module link, `mark_connected` | IP addresses, FHRP group assignments, untagged/tagged VLANs, 802.1Q mode, MTU, description, tags | +| cable, interface type, module link, `mark_connected` | interface VRF, IP addresses, FHRP group assignments, untagged/tagged VLANs, 802.1Q mode, MTU, description, tags | Custom field values are copied to the channel rather than moved, because they can describe either the port or the link. The remaining siblings are retyped in -place, so their own addresses, descriptions and tags — and their interface IDs — +place, so their own addresses, descriptions, tags, and interface IDs survive. The caveat worth reading twice: the ch-0 interface keeps its ID and becomes the @@ -205,7 +207,9 @@ and says so when more are waiting, and it refuses a confirmation naming more tha that many rather than converting part of it. **Convert as Background Job** runs every convertible family of the rule and is not capped. -On NetBox 4.6 and older no family is offered and no conversion section is shown. +On a NetBox release that cannot model channelized interfaces, each installed flat family is shown as +unsupported without a conversion checkbox. A direct conversion request returns the same explicit +unsupported family outcome and changes no row. ### Partial breakout repair diff --git a/docs/index.md b/docs/index.md index 6171ab5..a217a51 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,7 +19,7 @@ automatically apply renaming rules based on configurable templates. - **Template variables** — `{slot}`, `{bay_position}`, `{bay_position_num}`, `{channel}`, etc. - **Arithmetic expressions** — `{8 + ({parent_bay_position} - 1) * 2 + {sfp_slot}}` - **Breakout support** — create multiple channel interfaces from a single port -- **Regex pattern matching** — match module types by regex pattern (e.g., `QSFP-DD-400G-.*`) to cover entire product families with a single rule; exact FK match takes priority over regex +- **Bounded regex pattern matching** — match module types with RE2 patterns (e.g., `QSFP-DD-400G-.*`) to cover entire product families with a single rule; exact FK match takes priority over regex - **Scoping** — rules can target specific device types, parent module types, platforms, or be universal - **Build Rule tester** — interactive form to preview name output and test against installed interfaces before saving - **Apply Rules** — batch rename existing interfaces with live preview and background job support diff --git a/docs/installation.md b/docs/installation.md index 0e80c3c..92dbc35 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -21,6 +21,18 @@ PLUGINS = ["netbox_interface_name_rules"] ## Run Database Migrations +The migration audits every existing nonempty **Module Type Pattern** before the +plugin starts executing stored patterns with RE2. It stops when RE2 cannot +compile a pattern or when a Python shorthand can change its Unicode behavior. +The latter check includes `\d`, `\s`, `\w`, word boundaries, case-insensitive +matching, and POSIX character classes. The migration lists the affected +Interface Name Rule IDs and does not modify any rule. It also stops on Python +counted repeats that RE2 would treat as literal text, such as `{,3}` or `{01}`. +Rewrite those patterns with explicit +[RE2 syntax](https://github.com/google/re2/wiki/syntax), then run the migration +again. For example, use `[0-9]` for ASCII digits, `\p{L}` for Unicode letters, +and `{0,3}` for a repeat with an omitted Python lower bound. + ```bash cd /opt/netbox/netbox python manage.py migrate diff --git a/docs/template-variables.md b/docs/template-variables.md index 56c5412..2911263 100644 --- a/docs/template-variables.md +++ b/docs/template-variables.md @@ -184,19 +184,23 @@ a `parent_name_template` set instead offers each such family for conversion on * Preview & Apply**, where the operator confirms it per family; a blank parent template offers nothing, because a flat family's ch-0 interface is the base and has nowhere else to go. -Every family is preflighted by performing the conversion inside a transaction that is rolled back, -so the verdict carries NetBox's own reason for refusing it — a cabled sibling, an occupied parent -name, a missing sibling — and a refused family is never half converted. +The plugin rejects missing members locally before a conversion transaction starts. Its local +preflight also refuses a stale family, an occupied parent name, a sibling that already belongs to +another channel family, and a cabled sibling. Each of those verdicts is the plugin's own, and no +row is written. Only a family that passes preflight reaches NetBox's own rules. The plugin runs +that rewrite inside a transaction and rolls it back when NetBox refuses a row or a name collides, +so the verdict then carries NetBox's own reason. A preview rolls back a successful rewrite the same +way. The plugin never half converts a refused family. Converting keeps the physical row: the ch-0 interface keeps its interface ID, cable, type, module -link and `mark_connected`, and becomes the parent. Its IP addresses, FHRP group assignments, +link and `mark_connected`, and becomes the parent. Its interface VRF, IP addresses, FHRP group assignments, untagged/tagged VLANs, 802.1Q mode, MTU, description and tags move to a newly created channel 1 interface that takes over its name; custom field values are copied to it. The remaining siblings are retyped in place, keeping their own interface IDs. Automation keyed on the ch-0 interface ID -addresses the parent afterwards, not the channel that carries its name. +addresses the parent afterward, not the channel that carries its name. -On NetBox 4.6 and older no family is offered and conversion reports that this release cannot model -channels. +On a NetBox release that cannot model channels, each flat family is shown as unsupported without a +conversion action, and direct conversion reports the same explicit family outcome. A family installed by a rule that uses `{base}` carries the raw name it was named with, which on a module type using the `{vc_position}` template token may predate a chassis position change (see diff --git a/netbox_interface_name_rules/engine.py b/netbox_interface_name_rules/engine.py index 5c5c4e9..db32ad2 100644 --- a/netbox_interface_name_rules/engine.py +++ b/netbox_interface_name_rules/engine.py @@ -6,255 +6,54 @@ after Django is fully initialised. """ -import ast -import contextlib -import copy import logging -import re -import threading -from collections import defaultdict, namedtuple +from collections import defaultdict from django.core.exceptions import ValidationError -from django.db import IntegrityError, transaction -from django.db.models import Aggregate, F, TextField, Value -from django.db.models.functions import Cast, Coalesce, Concat, Length +from django.db import IntegrityError -from .choices import BreakoutModeChoices +from . import family as family_ops +from . import naming, rule_selection +from .family import targets as family_targets +from .family import template_names as family_template_names +from .regex_safety import compile_module_type_pattern logger = logging.getLogger(__name__) -# In-process cache of the enabled InterfaceNameRule set, keyed by a cheap fingerprint -# of that set. find_matching_rule() is called once per module row when a device's -# module-sync table is rendered, and each call used to run a DB query per scope -# candidate per tier; loading the (small) rule set once and matching in memory removes -# that per-row query storm. The fingerprint (see _enabled_rules_version) is re-read once -# per call and the set reloaded only when it changes, so it self-invalidates on any -# create/delete/edit — including raw bulk ``.update()`` of any matching field and SET_NULL -# cascades that bypass auto_now, and across test transactions — with no signal wiring. -# A reload publishes the new set by rebinding this name to a fresh dict in one atomic assignment -# (see _get_enabled_rules), so a concurrent reader on another worker thread sees either the whole -# old set or the whole new one — never exact/regex/memo torn across two versions. -_RULE_CACHE = {"version": None, "exact": (), "regex": (), "memo": {}} - -# Per-version cap on the find_matching_rule memo. A long-lived worker that sees many distinct -# (module_type, scope) contexts under a stable rule set would otherwise grow it without bound; -# on overflow the memo is cleared wholesale and rebuilt lazily. The cap is far above the number -# of contexts in any single module-sync render, so it never churns mid-render. -_MEMO_MAX = 4096 - -# Sentinel for the memo read. A single ``memo.get(sig, _MEMO_MISS)`` is one atomic dict lookup, so a -# concurrent ``memo.clear()`` at the cap can't wedge between a membership test and the subscript the -# way ``if sig in memo: return memo[sig]`` could — that compound read can raise a sporadic KeyError -# under threaded workers sharing one per-version memo. A real "no rule matched" is memoized as None, -# so the miss sentinel must be a distinct object, not None. -_MEMO_MISS = object() - -# Per-thread pin depth (see pinned_rule_cache). While > 0 on the current thread, _get_enabled_rules -# trusts the loaded set without re-reading the fingerprint, so an internal batch that wraps its loop -# turns N per-call fingerprint queries into one. Thread-local so concurrent requests don't affect -# each other; the depth defaults to 0, so any path that does not pin (the per-row signal handler, -# single applies) re-reads the fingerprint on every call exactly as before. -_pin = threading.local() - - -@contextlib.contextmanager -def pinned_rule_cache(): - """Pin the enabled-rule set for the duration of the block, skipping the per-call fingerprint query. - - find_matching_rule() normally re-reads a cheap fingerprint on every call to detect rule-set - changes. An internal batch that makes many lookups against an unchanging rule set — e.g. - _apply_rules_for_device_deferred(), which re-applies rules to every module on a device after a - virtual-chassis change — can wrap its loop in this context manager so the set is loaded and - fingerprinted once and reused for every call inside the block, turning N fingerprint queries - into one:: - - with pinned_rule_cache(): - for module in modules: - apply_interface_name_rules(module, module.module_bay, force_reapply=True) - - This is an INR-internal helper: only code that owns the batch loop pins, so it never becomes a - cross-plugin dependency. Other plugins integrate through the ``predict_module_interface_names`` - signal, which is dispatched one row at a time — so an external caller neither can nor needs to - pin, and INR exposes no batch API across the plugin boundary. - - Scope is explicit and per-thread: outside the block (and in any other thread/request) the normal - self-invalidating behaviour is unchanged, so there is no global staleness window. Nesting is - safe — only the outermost block manages the pin. Priming is lazy: the first find_matching_rule - inside the block does the one real fingerprint read + reload and captures that rule set into - thread-local state; the rest reuse that *snapshot* — so a block that makes no lookups does no - query at all, and a concurrent request reloading the shared cache on another thread cannot switch - this batch's rule set mid-loop. The loop may rename interfaces, but it must not edit rules: edits - to InterfaceNameRule made *inside* the block are not observed until it exits. - """ - depth = getattr(_pin, "depth", 0) - _pin.depth = depth + 1 - if depth == 0: - _pin.primed = False # the first lookup inside primes the cache (lazily — an empty block stays query-free) - try: - yield - finally: - _pin.depth -= 1 - if _pin.depth == 0: - # Release the per-thread snapshot so the next block re-primes from the live cache. - _pin.primed = False - for attr in ("exact", "regex", "memo"): - _pin.__dict__.pop(attr, None) - - -def _compile_pattern(pattern): - """Compile a regex *pattern* once, returning the compiled object or None for an invalid pattern. - - A None result is skipped at match time, mirroring the previous per-match ``try/except re.error`` - without recompiling the pattern on every lookup. - """ - try: - return re.compile(pattern) - except re.error: - return None - -# Rule columns that affect matching or output, in a fixed order. The enabled-set fingerprint -# (see _enabled_rules_version) is an md5 over these for every enabled rule, so it changes on ANY -# edit that could change a lookup result. ``description`` is excluded — it is operator notes that -# never affect matching. ``id`` anchors each row to its identity, so a compensating swap between -# two rules (which leaves count + column sums unchanged) still changes the hash. -_VERSION_COLUMNS = ( - "id", - "module_type_id", - "module_type_is_regex", - "module_type_pattern", - "parent_module_type_id", - "device_type_id", - "platform_id", - "name_template", - "parent_name_template", - "breakout_mode", - "channel_count", - "channel_start", - "applies_to_device_interfaces", -) - - -class _Md5OrderedStringAgg(Aggregate): - """``md5(string_agg(, ORDER BY id))`` expressed as one ORM aggregate. - - Built through the ORM rather than a hand-formatted SQL string, so the table/column identifiers are - quoted by Django's compiler and the delimiter is a bound parameter — there is no string-interpolated - SQL to audit for injection. The template is ours, so it does not depend on ``StringAgg``'s Python - signature, which differs across the Django 5.x–6.x versions in CI. ``ORDER BY id`` makes string_agg - deterministic — a stable row order yields a stable hash for an unchanged set. - """ +def pinned_rule_cache(): + """Return the lower-level rule cache pinning context.""" + return rule_selection.pinned_rule_cache() - function = "STRING_AGG" - template = "MD5(%(function)s(%(expressions)s ORDER BY id))" - output_field = TextField() +def find_matching_rule(module_type, parent_module_type, device_type, platform=None): + """Delegate rule selection while preserving the engine entry point.""" + return rule_selection.find_matching_rule(module_type, parent_module_type, device_type, platform) -def _version_row_signature(): - """Build the per-rule text signature expression for the fingerprint. - Each matching/output column is cast to text and emitted length-prefixed as ``:``. - That makes the row encoding self-delimiting: distinct column tuples can never serialize to the same - string, even if a text column (name_template / module_type_pattern) contains digits, a colon, or the - control characters a plain separator scheme would rely on being absent. A nullable FK is coalesced to - '' (length 0), so null stays distinct from any value while keeping the column's slot. - """ - empty = Value("", output_field=TextField()) - colon = Value(":", output_field=TextField()) - parts = [] - for column in _VERSION_COLUMNS: - cast = Cast(F(column), output_field=TextField()) - value = Coalesce(cast, empty, output_field=TextField()) if column.endswith("_id") else cast - parts.append(Cast(Length(value), output_field=TextField())) - parts.append(colon) - parts.append(value) - return Concat(*parts, output_field=TextField()) - - -# The signature expression is constant, so build it once and reuse it across calls. -_ROW_SIGNATURE = _version_row_signature() - - -def _enabled_rules_version(): - """Return a deterministic content fingerprint of the enabled-rule set. - - Computed server-side as an md5 over every enabled rule's matching/output columns, row-ordered - by pk, so the fingerprint changes on ANY edit that could change a lookup — including a raw bulk - ``.update()`` of a text field (name_template / module_type_pattern) or a boolean - (module_type_is_regex), which an aggregate of counts/sums cannot see, and a compensating edit - that keeps the column sums constant. It is one query returning a single 32-char hash, so it - stays cheap enough to re-read on every call. - - Each column is length-prefixed (see _version_row_signature), so the per-row encoding is - self-delimiting and rows concatenate unambiguously — distinct rule sets cannot collide even if a - text column contains arbitrary bytes. A nullable FK renders as the empty string (a real id never - does), keeping null distinct from any value. The empty set aggregates to NULL → coalesced to a - stable empty fingerprint, so "no enabled rules" is fixed. - """ - from .models import InterfaceNameRule +def _extract_trailing_digits(value: str) -> str: + """Delegate trailing-digit extraction while preserving the engine helper.""" + return naming._extract_trailing_digits(value) - return InterfaceNameRule.objects.filter(enabled=True).aggregate( - fingerprint=Coalesce( - _Md5OrderedStringAgg(_ROW_SIGNATURE, Value("", output_field=TextField())), - Value("", output_field=TextField()), - ) - )["fingerprint"] +def _resolve_bay_position(module_bay): + """Delegate bay-position resolution while preserving the engine helper.""" + return naming._resolve_bay_position(module_bay) -def _get_enabled_rules(): - """Return ``(exact_rules, regex_rules, memo)``, reloading only when the rule set changes. - ``exact_rules`` are ordered by ``(module_type__model, pk)`` to mirror the model's default - ordering (so an in-memory ``first match`` equals the previous ``.first()``). ``regex_rules`` - is a tuple of ``(compiled_pattern, rule)`` pairs, pre-sorted once by ``(-pattern length, pk)`` - and with each pattern compiled once, so the regex tier neither re-sorts nor recompiles per - call. ``memo`` caches find_matching_rule results for the current rule-set version. +def _resolve_slot(module_bay, bay_position_num, parent_bay_position): + """Delegate slot resolution while preserving the engine helper.""" + return naming._resolve_slot(module_bay, bay_position_num, parent_bay_position) - Inside a ``pinned_rule_cache()`` block on this thread, once the set has been primed (by the first - lookup in the block) the fingerprint query is skipped and the snapshot captured at prime time is - returned — never the live ``_RULE_CACHE``, which another thread may reload mid-block. - """ - global _RULE_CACHE - pinned = getattr(_pin, "depth", 0) > 0 - if pinned and getattr(_pin, "primed", False): - # Serve the snapshot captured when this block primed, not the shared cache: a concurrent - # request on another thread may reload _RULE_CACHE to a different version while we iterate, and - # a pinned batch must match every item against one consistent rule set. - return _pin.exact, _pin.regex, _pin.memo +def build_variables(module_bay, device=None): + """Delegate naming-variable construction while preserving the engine entry point.""" + return naming.build_variables(module_bay, device=device) - from .models import InterfaceNameRule - # Read the module global exactly once. A reload below publishes the new set by rebinding - # _RULE_CACHE to a brand-new dict (a single atomic name assignment) rather than mutating this - # one in place, so this local is a consistent snapshot: exact/regex/memo can never be torn - # across two rule-set versions even if another thread reloads between the reads at the end. - cache = _RULE_CACHE - version = _enabled_rules_version() - if cache["version"] != version: - rules = list(InterfaceNameRule.objects.filter(enabled=True).order_by("module_type__model", "pk")) - exact = tuple(r for r in rules if not r.module_type_is_regex) - regex_rules = sorted( - (r for r in rules if r.module_type_is_regex), - key=lambda r: (-len(r.module_type_pattern or ""), r.pk), - ) - regex = tuple((_compile_pattern(r.module_type_pattern), r) for r in regex_rules) - # Publish the whole new version atomically: a reader either sees the old dict or this one, - # never a mix of the two. Last writer wins; a concurrent reload to the same version just - # rebuilds redundantly, never corrupts. - cache = {"version": version, "exact": exact, "regex": regex, "memo": {}} - _RULE_CACHE = cache - if pinned: - # Pin this thread to the freshly-resolved set for the rest of the block. The tuples are - # immutable and the memo is COPIED into thread-local state — not aliased — so the pinned batch - # neither shares nor races the global memo: another thread clearing the shared memo at the cap - # can't evict our warmed entries (or wedge a KeyError) mid-loop, and entries we add stay private. - _pin.exact = cache["exact"] - _pin.regex = cache["regex"] - _pin.memo = dict(cache["memo"]) - _pin.primed = True - return _pin.exact, _pin.regex, _pin.memo - return cache["exact"], cache["regex"], cache["memo"] +def evaluate_name_template(template: str, variables: dict) -> str: + """Delegate template evaluation while preserving the engine entry point.""" + return naming.evaluate_name_template(template, variables) def _get_parent_module_type(module_bay): @@ -271,32 +70,13 @@ def _get_parent_module_type(module_bay): def supports_channelization(): - """Return True when this NetBox models channelized subinterfaces (NetBox 4.7+). - - Probed from the Interface model rather than a version comparison, so a backport or a - development build is detected by what it actually provides. - """ - from dcim.models import Interface - from django.core.exceptions import FieldDoesNotExist - - try: - Interface._meta.get_field("channel_id") - except FieldDoesNotExist: - return False - return True # pragma: no cover - only reachable on a NetBox that models channelization + """Delegate the channelization capability check while preserving the engine entry point.""" + return family_ops.supports_channelization() def _vc_position_re(): - """Return NetBox's ``{vc_position}`` template-token regex, or None on a release without the token. - - Imported inside the function so the probe reads the module as it stands at call time, matching - ``supports_channelization()``'s style. - """ - try: - from dcim.constants import VC_POSITION_RE - except ImportError: - return None - return VC_POSITION_RE # pragma: no cover - only reachable on a NetBox that resolves the token + """Delegate virtual-chassis token detection to template-name resolution.""" + return family_template_names.vc_position_re() def supports_vc_position_token(): @@ -308,59 +88,6 @@ def supports_vc_position_token(): return _vc_position_re() is not None -def _is_channel_child(iface): - """Return True when *iface* is a channel subinterface bound to a parent's channel. - - Structural, not name-based: a row imported without ``full_clean()`` may carry a ``channel_id`` - without the channel type. ``channel_id`` does not exist before NetBox 4.7, so this is False - on every older release and the family paths below stay dormant there. - """ - return getattr(iface, "channel_id", None) is not None - - -def _is_channelized_parent(iface): - """Return True when *iface* declares a channel count. - - A channelized parent owns a family even when no subinterface is bound yet — this, not the - presence of children, is what disables flat channel creation. - """ - return getattr(iface, "channels", None) is not None - - -def _partition_families(interfaces): - """Split *interfaces* into ``(bases, children_by_parent_pk)``. - - Bases are the interfaces a rule may match on its own: standalone interfaces and channelized - parents. Channel subinterfaces are never independent candidates — they are renamed only by - following their parent, so they are grouped under it instead. - """ - bases = [] - children = defaultdict(list) - for iface in interfaces: - if not _is_channel_child(iface): - bases.append(iface) - continue - children[iface.parent_id].append(iface) # pragma: no cover - requires channelization support - for group in children.values(): # pragma: no cover - no channel children exist without support - group.sort(key=lambda child: child.channel_id) - return bases, children - - -def _child_name_suffix(child_name, parent_name): # pragma: no cover - requires channelization support - """Return the suffix *child_name* adds to *parent_name*, or None when it adds none. - - The first character must be non-alphanumeric so ``et0``/``et01`` is never mistaken for a - family; the punctuation itself is free-form (``:``, ``-``, ``_`` and ``@`` all occur in the - wild), so it is not restricted to a fixed separator. - """ - if not parent_name or not child_name.startswith(parent_name): - return None - suffix = child_name[len(parent_name) :] - if not suffix or suffix[0].isalnum(): - return None - return suffix - - def _unambiguous_claims(candidates, matchers, module): # pragma: no cover - requires vc_position token support """Return the labels of *candidates* that exactly one drifted ``{vc_position}`` template claims. @@ -422,7 +149,7 @@ def _forced_channel_bases(interfaces, raw_names, matchers, module): for i in interfaces: # A channelized parent is its own base: its channels are separate rows, so the name needs no # ":"-splitting to find them. - base = i.name if _is_channelized_parent(i) else i.name.rsplit(":", 1)[0] + base = i.name if family_ops.is_channelized_parent(i) else i.name.rsplit(":", 1)[0] forms = (base, base.rsplit("/", 1)[-1]) if not any(form in raw_names for form in forms) and not any( matcher.pattern.fullmatch(form) for matcher in matchers for form in forms @@ -469,6 +196,31 @@ def _collect_unrenamed(interfaces, rule, raw_names, force_reapply, matchers=(), return _forced_channel_bases(interfaces, raw_names, matchers, module) +def _touches_a_family(plan) -> bool: + """Return whether *plan* acts on a family rather than one standalone interface.""" + if isinstance(plan, family_ops.InstalledFamilyPlan): + return plan.parent_pk is not None or len(plan.members) > 1 + return True + + +def _admitted_installed(plans, rule, raw_names, force_reapply, matchers, module): + """Return the installed families this install path should execute. + + A channelized family is always executed: its parent decides the family's names, and the raw-name + guard describes flat rows. A flat family is executed while the guard still claims a member of it. + """ + flat = [plan for plan in plans if plan.topology == family_ops.FamilyTopology.FLAT] + snapshots = [member.snapshot for plan in flat for member in plan.members] + selected = { + interface.pk for interface in _collect_unrenamed(snapshots, rule, raw_names, force_reapply, matchers, module) + } + return [ + plan + for plan in plans + if plan.topology == family_ops.FamilyTopology.CHANNELIZED or selected.intersection(plan.member_pks) + ] + + def apply_interface_name_rules(module, module_bay, force_reapply=False): """Apply InterfaceNameRule rename after module installation. @@ -480,162 +232,76 @@ def apply_interface_name_rules(module, module_bay, force_reapply=False): ``force_reapply=True`` to skip this check and re-apply rules to ALL module interfaces (used when vc_position or other variables change). - A channelized parent and its channel subinterfaces are processed as one family: the parent - decides, the children follow it (see ``_apply_rule_with_family``). + Every rename and every creation goes through the family package, so this path builds and names + exactly what retroactive apply and the preview describe. Returns: Number of interfaces renamed/created, or 0 if no rule matched. """ - from dcim.models import Interface - device_type = module.device.device_type if module.device else None platform = module.device.platform if module.device else None rule = find_matching_rule(module.module_type, _get_parent_module_type(module_bay), device_type, platform) if not rule: return 0 + # One pin for the module: the raw-name matchers and the family planner resolve its templates once. + with family_ops.pinned_template_cache(): + return _apply_rule_to_module(rule, module, module_bay, force_reapply) - variables = build_variables(module_bay, device=module.device) - interfaces = list(Interface.objects.filter(module=module)) - if not interfaces: - return 0 +def _apply_rule_to_module(rule, module, module_bay, force_reapply): + """Plan and execute every family *rule* intends on *module*; see ``apply_interface_name_rules``.""" + from dcim.models import Interface - # Only bases are rule candidates; the idempotency guard therefore looks at them alone. - bases, children_by_parent = _partition_families(interfaces) - # Determine raw names NetBox assigned from templates; fall back to bay_position. + variables = build_variables(module_bay, device=module.device) raw = _raw_name_matchers(module) raw_names = raw.names or {variables["bay_position"]} - unrenamed = _collect_unrenamed(bases, rule, raw_names, force_reapply, raw.matchers, module) - - if not unrenamed: - return 0 # Already renamed (idempotent guard) - - # A breakout rule on a module that has channelized families processes only those families — - # the same rule the preview and bulk-apply paths follow. - families_only = rule.channel_count > 0 and any(_is_channelized_parent(base) for base in bases) - - renamed = 0 - families_seen = families_only - conflicts: list = [] - for iface in unrenamed: - children = children_by_parent.get(iface.pk, ()) - if families_only and not _is_channelized_parent(iface): # pragma: no cover - see families_only above - logger.debug( - "Interface %r is not channelized; skipping it while rule '%s' breaks out this module's families.", - iface.name, - rule, - ) - continue - families_seen = families_seen or bool(children) or _is_channelized_parent(iface) - try: - count = _apply_rule_with_family(rule, iface, children, variables, module, conflicts) - except (ValueError, ValidationError, IntegrityError): - # The collision pre-check closes the common case, but a concurrent - # insert can still win between that check and the save — surfacing - # here as IntegrityError/ValidationError out of the per-interface - # atomic block (which has already rolled back cleanly). Log and keep - # going so one racing interface never aborts the whole install batch, - # mirroring apply_rule_to_existing(). - logger.exception( - "Failed to apply rule '%s' to interface '%s' (id=%s); skipping.", - rule, - iface.name, - iface.pk, - ) - continue - if count is None: - # A structural skip (unsupported topology, channel-count mismatch) says nothing about the rule. - families_seen = True - continue - renamed += count + interfaces = list( + Interface.objects.using(family_ops.module_db_alias(module)).filter(module_id=module.pk).order_by("pk") + ) + planned = family_ops.plan_module_families( + module, + rule, + variables, + interfaces, + # The guard runs while it can still see every claimed row, before two of them that intend + # one family are collapsed into it. + admit_leftover=lambda plain: _collect_unrenamed(plain, rule, raw_names, force_reapply, raw.matchers, module), + ) + installed = _admitted_installed(planned.installed, rule, raw_names, force_reapply, raw.matchers, module) + leftover = planned.leftover + + outcomes = family_ops.execute_module_families(rule, module, [*installed, *leftover]) + renamed = sum(outcome.changed_count for outcome in outcomes) + blocked = [ + member for outcome in outcomes for member in outcome.members if member.status == family_ops.FamilyStatus.BLOCKED + ] + families_seen = bool(installed) or any(_touches_a_family(plan) for plan in leftover) - if unrenamed and renamed == 0 and not conflicts and not families_seen: + if leftover and renamed == 0 and not blocked and not families_seen: # All interfaces already have the names the rule would produce — flag as # potentially obsolete (e.g., newer NetBox generates correct names natively). # Skipped when the 0-count was caused by name collisions (a different reason # than a no-op rule), so a collision never mislabels the rule as deprecated. - # Skipped for channelized families too: a structural skip, or a family whose parent - # deliberately keeps its raw name, says nothing about the rule being obsolete. + # Skipped for families too: a structural skip, or a family whose parent deliberately + # keeps its raw name, says nothing about the rule being obsolete. _flag_rule_potentially_deprecated(rule) return renamed -def _predicted_channel_name(rule, raw_name, variables, parents, children): # pragma: no cover - channelized only - """Return the name the channel template named *raw_name* takes under *rule*.""" - parent_name, channel_id = children[raw_name] - if rule.channel_count > 0: - if parents.get(parent_name) != rule.channel_count: - return raw_name # channel-count mismatch: the apply path skips the whole family - channel = str(rule.channel_start + channel_id - 1) - return evaluate_name_template(rule.name_template, {**variables, "base": parent_name, "channel": channel}) - # Simple rule: the channel follows its parent, keeping the suffix it adds to the parent's name. - parent_target = evaluate_name_template(rule.name_template, {**variables, "base": parent_name}) - suffix = _child_name_suffix(raw_name, parent_name) - return raw_name if suffix is None else parent_target + suffix - - -def _predicted_family_parent_name(rule, raw_name, variables, parents): # pragma: no cover - channelized only - """Return the name the parent template named *raw_name* takes under *rule*. - - Only a channelized rule that names its parent renames it, and only when the family's channel - count is the one the rule describes — the same two conditions the apply path applies. - """ - if not (_is_channelized_rule(rule) and rule.parent_name_template): - return raw_name - if parents.get(raw_name) != rule.channel_count: - return raw_name # channel-count mismatch: the apply path skips the whole family - return evaluate_name_template(rule.parent_name_template, {**variables, "base": raw_name}) - - -def _predicted_names(rule, raw_name, variables, parents, children, family_blocked=False): - """Return the names *raw_name* predicts to under *rule*. - - A name the module type's templates describe as a channelized parent or channel follows its - family; a channelized rule on a plain name predicts the family it would build there, unless - *family_blocked* says the apply path refuses to build it; anything else keeps the per-name - prediction, expanding once per channel for a breakout rule and once for a simple one. - """ - if raw_name in parents: # pragma: no cover - requires a NetBox that models channelization - if rule.channel_count > 0: - # The rule renames the family's existing channels; only a parent template moves the parent. - return [_predicted_family_parent_name(rule, raw_name, variables, parents)] - return [evaluate_name_template(rule.name_template, {**variables, "base": raw_name})] - if raw_name in children: # pragma: no cover - requires a NetBox that models channelization - return [_predicted_channel_name(rule, raw_name, variables, parents, children)] - if rule.channel_count > 0 and _is_channelized_rule(rule): - if family_blocked or not supports_channelization(): - return [raw_name] # the apply path builds nothing here - parent_name, channels = _channelized_family_names(rule, raw_name, variables) # pragma: no cover - see above - return [parent_name, *(name for _, name in channels)] # pragma: no cover - see above - vars_copy = {**variables, "base": raw_name} - if rule.channel_count > 0: - return [ - evaluate_name_template(rule.name_template, {**vars_copy, "channel": str(rule.channel_start + ch)}) - for ch in range(rule.channel_count) - ] - return [evaluate_name_template(rule.name_template, vars_copy)] - - def predict_rule_output(module, module_bay, raw_names): """Predict the names apply_interface_name_rules would produce for raw_names. - Read-only — saves and mutates nothing. A channelized rule additionally counts the module's - interfaces, because the apply path refuses to convert a module that already carries a flat - breakout family and the prediction has to say the same. Used by external integrations (e.g., + Read-only: saves and mutates nothing. Used by external integrations (e.g. netbox-librenms-plugin) that need to know the post-rename names without applying any rule. - For breakout rules (channel_count > 0), each raw name expands to - channel_count predicted names. For simple renames, one name in → one name - out. Returns raw_names unchanged when no rule matches or evaluation fails. - - A name the module type's interface templates describe as part of a channelized family is - predicted as the apply path treats it instead: the family's channels are renamed in place, so a - breakout rule leaves the parent's name alone and maps each channel through its ``channel_id`` - rather than expanding one name into a flat set. Names no template claims keep the per-name - prediction, so a module type without channelized templates is unaffected. + The names are planned by the family module from the module type's templates, so prediction + describes the same families installed execution builds: a breakout rule expands one plain name + into the family it creates, a name the templates describe as part of a channelized family + follows that family instead, and a family the apply path refuses to touch predicts unchanged. + Returns raw_names unchanged when no rule matches. Precondition: *raw_names* are resolved by the caller at call time. A name captured before the device's virtual-chassis position changed is predicted from itself, not corrected to the name @@ -647,132 +313,113 @@ def predict_rule_output(module, module_bay, raw_names): if not rule: return list(raw_names) - variables = build_variables(module_bay, device=module.device) - parents, children = _template_families(module) - # Costs one count pair, and only where a channelized rule could otherwise predict a family. - family_blocked = ( - rule.channel_count > 0 - and _is_channelized_rule(rule) - and supports_channelization() - and _has_flat_expansion(module) + plan_set = family_ops.plan_prospective_families( + module, + rule, + build_variables(module_bay, device=module.device), + family_ops.describe_module_interfaces(module, raw_names), ) + return [name for raw_name in raw_names for name in plan_set.predicted_names(raw_name)] - output = [] - for raw_name in raw_names: - try: - output.extend(_predicted_names(rule, raw_name, variables, parents, children, family_blocked)) - except (ValueError, TypeError, re.error): - # Template eval failed; apply path would also fail and leave the - # interface alone, so the predicted name is the raw name. - output.append(raw_name) - - return output +def reapply_module_rules(device): + """Re-apply module rules to every module on *device* after its virtual-chassis position changed. -def _try_rename_device_interface(rule, iface, vc_position, device, renamed_pks, conflicts=None): - """Attempt to rename a single device-level interface using *rule*. + The whole device is one batch: its modules match against one enabled-rule snapshot, and one + module type's interface templates are read once however many modules carry it. A failure is + logged and leaves that module behind. Remaining modules continue with the new position. - Returns ``True`` if the interface was successfully renamed, ``False`` otherwise. - Mutates ``renamed_pks`` on success. - - A computed name already taken by another interface on the device is skipped - with a tidy WARNING (no traceback), mirroring the module-install path; pass a - list as *conflicts* to also collect them. ``full_clean()`` remains the - backstop for the rarer cross-member (VC) uniqueness violation. + Returns the number of interfaces renamed across the device's modules. """ - if iface.pk in renamed_pks: - return False # Already renamed by a higher-priority rule - - if rule.module_type_pattern: - try: - if not re.fullmatch(rule.module_type_pattern, iface.name): - return False - except re.error: - return False - - port = iface.name.rsplit("/", 1)[-1] if "/" in iface.name else iface.name - variables = {"vc_position": vc_position, "base": iface.name, "port": port} + from dcim.models import Module - try: - new_name = evaluate_name_template(rule.name_template, variables) - except (ValueError, TypeError, re.error): - logger.exception( - "Failed to evaluate template %r for interface %s (rule %s)", - rule.name_template, - iface.name, - rule.pk, + modules = list( + Module.objects.filter(device=device).select_related( + "module_type", + "device__device_type", + "device__platform", + *family_template_names.BAY_CHAIN_RELATIONS, ) - return False + ) + total = 0 + with pinned_rule_cache(), family_ops.pinned_template_cache(modules): + for module in modules: + if not module.module_bay: + continue + try: + total += apply_interface_name_rules(module, module.module_bay, force_reapply=True) or 0 + except Exception: + logger.exception( + "Failed to re-apply rules for %s in %s after a virtual-chassis change", + module.module_type, + module.module_bay.name, + ) + return total - if new_name == iface.name: - return False - # Pre-check device-scope name uniqueness so an expected collision is a clean - # WARNING + skip instead of an ERROR traceback out of full_clean(). - if _name_exists_on_device(device, new_name, exclude_pk=iface.pk): - _record_conflict(conflicts, device, iface.name, new_name, iface.pk) - return False +def _device_interface_rules(device): + """Return enabled device-interface rules in matching priority order.""" + from django.db.models import Q - old_name = iface.name - iface.name = new_name - try: - iface.full_clean() - except ValidationError as exc: - logger.warning( - "Validation failed renaming device interface %r → %r (rule %s, device %s); skipping: %s", - old_name, - new_name, - rule.pk, - device.pk, - exc, + from .models import InterfaceNameRule + + device_type = getattr(device, "device_type", None) + platform = getattr(device, "platform", None) + rules = list( + InterfaceNameRule.objects.filter( + applies_to_device_interfaces=True, + enabled=True, ) - iface.name = old_name - return False - try: - iface.save() - except (IntegrityError, ValidationError): - logger.exception( - "DB save failed for device interface %s → %s (rule %s, device %s)", - old_name, - new_name, - rule.pk, - device.pk, + .filter(Q(device_type=device_type) | Q(device_type__isnull=True)) + .filter(Q(platform=platform) | Q(platform__isnull=True)) + ) + # Sort Python-side: specificity_score descending, then module_type_pattern length + # descending (for device-interface rules with ties), then pk ascending for stability. + # (InterfaceNameRule has no DB 'priority' field; specificity_score is a property.) + rules.sort( + key=lambda r: ( + -r.specificity_score, + -len(r.module_type_pattern or ""), + r.pk, ) - iface.name = old_name - return False - - renamed_pks.add(iface.pk) - logger.debug("Renamed device interface %s → %s (rule %s, device %s)", old_name, new_name, rule.pk, device.pk) - return True + ) + return rules -def _try_rename_device_family(rule, iface, children, vc_position, device, renamed_pks, conflicts=None): - """Rename a device-level interface with *rule* and carry its channel subinterfaces along. +def _matches_device_interface(rule, interface): + """Return whether one device-interface rule matches one family parent.""" + if not rule.module_type_pattern: + return True + try: + compiled = compile_module_type_pattern(rule.module_type_pattern) + except ValidationError: + return False + return compiled.fullmatch(interface.name) is not None - Returns the number of interfaces renamed. The whole family is claimed in *renamed_pks* the - moment its parent is renamed, so a lower-priority rule can never rename the leftovers of a - family a higher-priority rule already took. - Healing is best-effort here: device-level interfaces have no module template family to recover - a suffix from, so a child that lost its parent's prefix in an earlier run is left alone. - """ - parent_before = iface.name - if not _try_rename_device_interface(rule, iface, vc_position, device, renamed_pks, conflicts): - return 0 - count = 1 - for child, target in _child_target_names( # pragma: no cover - requires channelization support - children, parent_before, iface.name, module=None - ): - renamed_pks.add(child.pk) - if target is None: - logger.warning( - "Cannot derive a name for channel interface %r from parent %r; leaving it unchanged.", - child.name, - iface.name, +def _apply_device_rule_to_families(device, vc_position, rule, families, claimed_pks): + """Apply one rule to each eligible device-interface family.""" + total = 0 + for interface, children in families: + if interface.pk in claimed_pks or not _matches_device_interface(rule, interface): + continue + port = interface.name.rsplit("/", 1)[-1] + variables = {"vc_position": vc_position, "base": interface.name, "port": port} + plan = family_ops.plan_device_interface_rename(device, rule, variables, interface, children) + try: + outcome = family_ops.execute_installed_plan(plan) + except (IntegrityError, ValidationError): + logger.exception( + "Failed to apply rule %s to device interface %r on device %s; skipping.", + rule.pk, + interface.name, + device.pk, ) continue - count += _rename_for_family(child, target, device, conflicts).count - return count + total += outcome.changed_count + if outcome.status in {family_ops.FamilyStatus.CHANGED, family_ops.FamilyStatus.UNCHANGED}: + claimed_pks.update(plan.member_pks) + return total def apply_device_interface_rules(device): @@ -784,7 +431,7 @@ def apply_device_interface_rules(device): Template variables available: ``{vc_position}``, ``{base}`` (full current name), ``{port}`` (segment after the last ``/``, or the full name if no ``/`` present). - Channel subinterfaces are not matched independently — they follow the parent whose family + Channel subinterfaces are not matched independently. They follow the parent whose family a rule wins, so a template like ``eth{vc_position}`` cannot collapse a whole family onto one name. @@ -792,8 +439,6 @@ def apply_device_interface_rules(device): """ from dcim.models import Interface - from .models import InterfaceNameRule - if not getattr(device, "virtual_chassis_id", None): return 0 # Only rename for VC members (vc_position must be set) @@ -801,136 +446,26 @@ def apply_device_interface_rules(device): return 0 # vc_position unset (e.g. VC master before position assigned) vc_position = str(device.vc_position) - device_type = getattr(device, "device_type", None) - platform = getattr(device, "platform", None) - - from django.db.models import Q - - rules = list( - InterfaceNameRule.objects.filter( - applies_to_device_interfaces=True, - enabled=True, - ) - .filter(Q(device_type=device_type) | Q(device_type__isnull=True)) - .filter(Q(platform=platform) | Q(platform__isnull=True)) - ) - # Sort Python-side: specificity_score descending, then module_type_pattern length - # descending (for device-interface rules with ties), then pk ascending for stability. - # (InterfaceNameRule has no DB 'priority' field; specificity_score is a property.) - rules.sort( - key=lambda r: ( - -r.specificity_score, - -(len(r.module_type_pattern or "") if r.applies_to_device_interfaces else 0), - r.pk, - ) - ) - + rules = _device_interface_rules(device) if not rules: return 0 - interfaces = list(Interface.objects.filter(device=device, module=None)) + interfaces = list(Interface.objects.filter(device=device, module=None).order_by("pk")) if not interfaces: return 0 - bases, children_by_parent = _partition_families(interfaces) + families = family_ops.device_interface_families(interfaces) + claimed_pks: set[int] = set() total = 0 - renamed_pks: set[int] = set() for rule in rules: - for iface in bases: - total += _try_rename_device_family( - rule, iface, children_by_parent.get(iface.pk, ()), vc_position, device, renamed_pks - ) + total += _apply_device_rule_to_families(device, vc_position, rule, families, claimed_pks) return total -# The bay chain InterfaceTemplate.resolve_name() dereferences while resolving {module}. -_BAY_CHAIN_RELATIONS = ( - "module_bay", - "module_bay__parent", - "module_bay__module", - "module_bay__module__module_bay", - "module_bay__module__module_bay__parent", - "module_bay__module__module_bay__module", -) - - -def _module_with_bay_chain(module): - """Re-fetch *module* with the bay chain InterfaceTemplate.resolve_name() dereferences. - - Prefetches the module relationships to avoid a per-template query when resolving names. - """ - from dcim.models import Module - - return Module.objects.select_related(*_BAY_CHAIN_RELATIONS).get(pk=module.pk) - - -# NetBox resolves {vc_position} once, at instantiation, so a raw name records the device's VC state -# at that moment while its template keeps resolving to the current one: hence names *and* matchers. -_RawMatcher = namedtuple("_RawMatcher", ("template_name", "resolved", "pattern")) -_RawNames = namedtuple("_RawNames", ("names", "matchers")) - -# Brace-free stand-ins, so NetBox's placeholder pass, this plugin's and re.escape() all leave them be. -_VC_SENTINEL = "InrVcPositionSentinel{}End" -_BASE_SENTINEL = "InrBaseSentinelEnd" - - -def _vc_position_alternatives(fallback): # pragma: no cover - requires vc_position token support - """Return the regex branch covering every value one ``{vc_position}`` occurrence resolves to. - - Any member position and the implicit ``'0'`` are digits; an explicit ``{vc_position:X}`` fallback - adds a branch of its own, since NetBox does not require it to be numeric. - """ - if fallback is None: - return r"\d+" - return f"(?:\\d+|{re.escape(fallback)})" - - -def _raw_name_pattern(tmpl, module, token_re): # pragma: no cover - requires vc_position token support - """Return the matcher for every name *tmpl* has ever resolved to, or None without the token. - - Each token occurrence becomes a sentinel; ``{module}`` is then resolved by NetBox's own code on a - shallow copy carrying that name (its VC pass no-ops on a token-free string), so no placeholder - resolution is reimplemented here. - """ - fallbacks = [] - - def _mark(match): - fallbacks.append(match.group(1)) - return _VC_SENTINEL.format(len(fallbacks) - 1) - - marked = token_re.sub(_mark, tmpl.name) - if not fallbacks: - return None - stub = copy.copy(tmpl) - stub.name = marked - pattern = re.escape(stub.resolve_name(module)) - for index, fallback in enumerate(fallbacks): - pattern = pattern.replace(_VC_SENTINEL.format(index), _vc_position_alternatives(fallback)) - return _compile_pattern(pattern) - - -def _raw_matchers(templates, module): - """Resolve *templates* against *module*: their names now, plus a matcher per token template.""" - token_re = _vc_position_re() - names = set() - matchers = [] - for tmpl in templates: - resolved = tmpl.resolve_name(module) - names.add(resolved) - pattern = None if token_re is None else _raw_name_pattern(tmpl, module, token_re) - if pattern is not None: - matchers.append(_RawMatcher(tmpl.name, resolved, pattern)) # pragma: no cover - token templates only - return _RawNames(names, matchers) - - def _raw_name_matchers(module): - """Return *module*'s raw template names and the drift matchers of its token templates.""" - from dcim.models import InterfaceTemplate - - module_fresh = _module_with_bay_chain(module) - templates = InterfaceTemplate.objects.filter(module_type=module_fresh.module_type) - return _raw_matchers(templates, module_fresh) + """Delegate current and historical raw name resolution.""" + return family_template_names.raw_name_matchers(module) def _get_raw_interface_names(module): @@ -939,124 +474,8 @@ def _get_raw_interface_names(module): def _raw_name_patterns(module): - """Return one compiled matcher per interface template of *module* whose name carries the token. - - Empty for a module type no template of which uses ``{vc_position}``, and on every NetBox release - that does not resolve the token at all. - """ - return [matcher.pattern for matcher in _raw_name_matchers(module).matchers] - - -def _raw_names_by_module(modules): # pragma: no cover - only the conversion scan batches names - """Return ``{module pk: _RawNames}`` for *modules*, in one template query for all of them. - - Raw names are a property of the module type, but ``_raw_name_matchers`` costs a module refetch - and a template query each — a scan over a fleet would pay that per module. *modules* must - already carry ``_BAY_CHAIN_RELATIONS``, since the names are resolved against them in memory. - """ - from dcim.models import InterfaceTemplate - - by_module_type = defaultdict(list) - for tmpl in InterfaceTemplate.objects.filter(module_type__in={module.module_type_id for module in modules}): - by_module_type[tmpl.module_type_id].append(tmpl) - return {module.pk: _raw_matchers(by_module_type[module.module_type_id], module) for module in modules} - - -def _template_families(module): - """Return ``(parents, children)`` describing *module*'s channelized interface templates. - - *parents* maps a channelized parent template's resolved name to its channel count; *children* - maps each channel template's resolved name to ``(parent_name, channel_id)``. Both are empty - where nothing can be channelized, so callers keep their pre-channelization behaviour without - paying for a template scan. - """ - if not supports_channelization(): - return {}, {} - return _resolve_template_families(module) # pragma: no cover - requires channelization support - - -def _resolve_template_families(module): # pragma: no cover - requires a NetBox that models channelization - """Resolve *module*'s interface templates into the channelized families they describe. - - Pairing through ``InterfaceTemplate.parent`` (rather than matching against the flat set of raw - names) keeps ambiguous prefixes like ``xe``/``xe-0`` apart, and a channel template whose parent - declares no channel count is not a family at all. - """ - from dcim.models import InterfaceTemplate - - module_fresh = _module_with_bay_chain(module) - templates = list(InterfaceTemplate.objects.filter(module_type=module_fresh.module_type)) - resolved = {tmpl.pk: tmpl.resolve_name(module_fresh) for tmpl in templates} - parents_by_pk = { - tmpl.pk: (resolved[tmpl.pk], tmpl.channels) for tmpl in templates if getattr(tmpl, "channels", None) is not None - } - children = {} - for tmpl in templates: - channel_id = getattr(tmpl, "channel_id", None) - parent = parents_by_pk.get(getattr(tmpl, "parent_id", None)) - if channel_id is None or parent is None: - continue - parent_name, _channels = parent - children[resolved[tmpl.pk]] = (parent_name, channel_id) - return dict(parents_by_pk.values()), children - - -def _template_channel_suffixes(module): # pragma: no cover - requires a NetBox that models channelization - """Map ``channel_id`` → the set of name suffixes *module*'s interface templates give that channel. - - The suffix comes from the template family itself — each channel template's resolved name minus - its parent template's resolved name — so a child that lost its parent's prefix in an earlier - partial rename can still be repaired. A module type with several families may spell the same - channel differently in each (``et0:2`` vs ``sw0.2``), so the suffixes are collected per channel - rather than overwritten: the recovery only uses one when every family agrees on it. - """ - suffixes = defaultdict(set) - for child_name, (parent_name, channel_id) in _template_families(module)[1].items(): - suffix = _child_name_suffix(child_name, parent_name) - if suffix is not None: - suffixes[channel_id].add(suffix) - return suffixes - - -def _recovered_suffix(child, suffixes): # pragma: no cover - requires channelization support - """Return the template suffix for *child*'s channel, or None when it is not unambiguous. - - Once a parent has been renamed there is no reliable way back from a stranded child to the family - it belongs to, so a channel spelled differently by two families is left alone rather than guessed. - """ - candidates = suffixes.get(child.channel_id) or set() - if len(candidates) == 1: - return next(iter(candidates)) - if candidates: - logger.warning( - "Channel %s is spelled %s by different families of this module type; " - "cannot recover a name for interface %r.", - child.channel_id, - sorted(candidates), - child.name, - ) - return None - - -def _child_target_names(children, parent_before, parent_after, module): - """Pair every child with the name it takes when its parent is renamed to *parent_after*. - - The suffix is read from the child's own name against *parent_before* (the parent's name before - this run's rename); when the child no longer carries that prefix the suffix is recovered from - the module's template family instead. A child that neither shares the prefix nor has an - unambiguous template pairing is returned with a None target — the engine leaves it alone rather - than guessing at a free-form name. - """ - suffixes = None - targets = [] - for child in children: # pragma: no cover - requires channelization support - suffix = _child_name_suffix(child.name, parent_before) - if suffix is None and module is not None: - if suffixes is None: - suffixes = _template_channel_suffixes(module) - suffix = _recovered_suffix(child, suffixes) - targets.append((child, None if suffix is None else parent_after + suffix)) - return targets + """Delegate historical raw-name pattern construction.""" + return family_template_names.raw_name_patterns(module) def _flag_rule_potentially_deprecated(rule): @@ -1087,735 +506,45 @@ def _flag_rule_potentially_deprecated(rule): logger.exception("Failed to flag rule '%s' as potentially-deprecated.", rule) -def _scope_ids(parent_module_type, device_type, platform): - """Map the (parent_module_type, device_type, platform) scope objects to their FK ids. +def _matching_moduletype_pks(module_type_pattern): + """Return PKs of ModuleTypes whose model name matches the given RE2 pattern. - ``None`` (no constraint) maps to ``None`` so it compares equal to a rule's unset scope FK. - Centralises the ``x.pk if x is not None else None`` coalescing used by both match tiers and - the memo key. + Raises ValueError for invalid patterns, mirroring evaluate_name_template's + error-handling convention so callers can treat both as ValueError. """ - return ( - parent_module_type.pk if parent_module_type is not None else None, - device_type.pk if device_type is not None else None, - platform.pk if platform is not None else None, - ) + from dcim.models import ModuleType + try: + compiled = compile_module_type_pattern(module_type_pattern) + except ValidationError as exc: + raise ValueError(exc.messages[0]) from exc + return [mt.pk for mt in ModuleType.objects.only("pk", "model") if compiled.fullmatch(mt.model)] -def _rule_scope_matches(rule, scope_ids): - """Return True when *rule*'s (parent_module_type, device_type, platform) FKs equal *scope_ids*.""" - pmt_id, dt_id, pl_id = scope_ids - return rule.parent_module_type_id == pmt_id and rule.device_type_id == dt_id and rule.platform_id == pl_id +def has_applicable_interfaces(rule) -> bool: + """Check whether applying this rule right now would rename at least one interface. -def _build_candidates(parent_module_type, device_type, platform) -> list: - """Build ordered list of (pmt, dt, pl) tuples from most to least specific. + Calls find_interfaces_for_rule(limit=1) to determine if any currently installed + interface would receive a new name. Returns False when: + - no matching modules/interfaces are installed, OR + - all matching interfaces are already correctly named. - Each argument expands to ``[value, None]`` when provided, or ``[None]`` - when already absent. Deduplication ensures no key appears twice (which - would happen when multiple inputs are None). - """ - seen: set = set() - candidates = [] - pmt_opts = [parent_module_type, None] if parent_module_type else [None] - dt_opts = [device_type, None] if device_type else [None] - pl_opts = [platform, None] if platform else [None] - for pmt in pmt_opts: - for dt in dt_opts: - for pl in pl_opts: - key = (pmt, dt, pl) - if key not in seen: - seen.add(key) - candidates.append(key) - return candidates - - -def _find_exact_match(module_type, candidates, exact_rules=None): - """Tier 1: return the first enabled exact-FK rule in specificity order, or None. - - ``exact_rules`` is the preloaded, ``(module_type__model, pk)``-ordered enabled - exact-rule set (the hot path passes it to avoid a DB query per call); when omitted - it is loaded on demand so direct callers keep working. + This is more expensive than a plain EXISTS query but ensures the Applicable + column in the Apply Rules list accurately reflects "would something change?" + rather than the misleading "do interfaces exist?". """ - if exact_rules is None: - exact_rules, _, _ = _get_enabled_rules() - - # module_type fixed below → the (module_type__model, pk) ordering reduces to pk, so the - # first matching rule equals the previous ``.filter(...).first()``. - scoped = [r for r in exact_rules if r.module_type_id == module_type.pk] - for candidate in candidates: - scope_ids = _scope_ids(*candidate) - for rule in scoped: - if _rule_scope_matches(rule, scope_ids): - return rule - return None + try: + results, _ = find_interfaces_for_rule(rule, limit=1) + return len(results) > 0 + except ValueError: + return False -def _find_regex_match(model_name: str, candidates, regex_rules=None): - """Tier 2: return the first enabled regex rule whose pattern fullmatches *model_name*, or None. +def _build_module_qs(rule): + """Return a Module queryset filtered to the rule's scope (module type, parent, device, platform). - Tries candidates in specificity order; within each level longer patterns are tried first - (more specific). ``regex_rules`` is the preloaded ``(compiled_pattern, rule)`` set — pre-sorted - by ``(-pattern length, pk)`` with each pattern compiled once (a None compile is an invalid - pattern, silently skipped); loaded on demand when omitted. - """ - if regex_rules is None: - _, regex_rules, _ = _get_enabled_rules() - - for candidate in candidates: - scope_ids = _scope_ids(*candidate) - for compiled, rule in regex_rules: - if compiled is not None and _rule_scope_matches(rule, scope_ids) and compiled.fullmatch(model_name): - return rule - return None - - -def find_matching_rule(module_type, parent_module_type, device_type, platform=None): - """Find the most specific InterfaceNameRule matching the context. - - Uses a two-tier strategy: - Tier 1 — Exact FK match (priority order, most specific first): - Iterates all combinations of (parent_module_type, device_type, platform) - from fully-constrained to fully-unconstrained (None = any). - Tier 2 — Regex pattern match (same priority order, longer patterns first): - Same specificity cascade, but module_type_pattern is matched via - re.fullmatch() against module_type.model. Patterns are iterated - from longest to shortest to prefer more specific patterns. - - The enabled rule set is loaded once and matched in memory, and the per-context - result is memoized for the current rule-set version, so repeated calls (e.g. one - per module row in a module-sync render) don't re-query the database. - - Returns the first matching rule, or None if no rule matches. - """ - if module_type is None: - # Module rules are always keyed on a module type; both tiers dereference it - # (module_type.pk / .model), so there is nothing to match without one. - return None - - exact_rules, regex_rules, memo = _get_enabled_rules() - # The regex tier matches against module_type.model (a live string), so the memo must key on - # it too — otherwise a ModuleType.model rename (same pk) would return a stale regex result. - sig = (module_type.pk, module_type.model, *_scope_ids(parent_module_type, device_type, platform)) - # One atomic lookup, not `if sig in memo: return memo[sig]`: another thread sharing this per-version - # memo can clear it at the cap between a membership test and the subscript, raising KeyError. - cached = memo.get(sig, _MEMO_MISS) - if cached is not _MEMO_MISS: - return cached - - candidates = _build_candidates(parent_module_type, device_type, platform) - result = _find_exact_match(module_type, candidates, exact_rules) or _find_regex_match( - module_type.model, candidates, regex_rules - ) - if len(memo) >= _MEMO_MAX: - memo.clear() # bound per-version memory; entries are rebuilt lazily on the next miss - memo[sig] = result - return result - - -def _extract_trailing_digits(s: str) -> str: - r"""Return the trailing digit run of *s* without regex backtracking. - - Pure O(n) string scan — eliminates the polynomial backtracking risk that - arises from using ``re.search(r"(\d+)$", ...)`` on strings ending in a - non-digit character (e.g. ``"1" * n + "x"`` would cause O(n²) steps). - - Returns an empty string when *s* has no trailing digits. - """ - i = len(s) - while i > 0 and s[i - 1].isdigit(): - i -= 1 - return s[i:] - - -def _resolve_bay_position(module_bay): - """Return (bay_position, bay_position_num) from a module bay's position field. - - Handles template expressions like ``{module}`` by extracting the trailing - digit from the bay name. Falls back to ``"0"`` if no digit is found. - """ - bay_position = module_bay.position or "0" - if bay_position.startswith("{"): - digits = _extract_trailing_digits(module_bay.name) - bay_position = digits if digits else "0" - digits = _extract_trailing_digits(bay_position) - bay_position_num = digits if digits else "0" - return bay_position, bay_position_num - - -def _resolve_slot(module_bay, bay_position_num, parent_bay_position): - """Return the ``slot`` variable from the module bay hierarchy. - - When the bay has a parent bay, slot comes from the parent (or grandparent - when two levels of nesting exist). When the bay belongs to an installed - module with its own bay, slot comes from that module's bay position. - Falls back to ``bay_position_num``. - """ - if module_bay.parent: - parent_bay = module_bay.parent - if parent_bay.parent and hasattr(parent_bay.parent, "installed_module"): - return parent_bay.parent.position or parent_bay_position - return parent_bay_position - if hasattr(module_bay, "module") and module_bay.module: - owner_module = module_bay.module - if hasattr(owner_module, "module_bay") and owner_module.module_bay: - return owner_module.module_bay.position or bay_position_num - return bay_position_num - - -def build_variables(module_bay, device=None): - """Build template variable dict from a module bay's position context. - - Extracts numeric and raw position values from the bay and its parent chain, - producing the variables available for name_template substitution. - - Returns a dict with keys: slot, bay_position, bay_position_num, - parent_bay_position, sfp_slot, and optionally vc_position. - - ``vc_position`` is only injected when *device* is a Virtual Chassis member - (device.virtual_chassis_id is set). Templates using ``{vc_position}`` on a - non-VC device will raise ValueError during evaluation — this is intentional. - Note: Juniper VC positions start at 0, so 0 is a valid real-world value and - cannot be used as a "not in VC" sentinel. - """ - bay_position, bay_position_num = _resolve_bay_position(module_bay) - - parent_bay_position = "0" - if module_bay.parent: - parent_bay_position = module_bay.parent.position or "0" - - slot = _resolve_slot(module_bay, bay_position_num, parent_bay_position) - - result = { - "slot": slot, - "bay_position": bay_position, - "bay_position_num": bay_position_num, - "parent_bay_position": parent_bay_position, - "sfp_slot": bay_position_num, - } - if ( - device is not None - and getattr(device, "virtual_chassis_id", None) is not None - and device.vc_position is not None - ): - result["vc_position"] = str(device.vc_position) - return result - - -def _name_exists_on_device(device, name, exclude_pk=None): - """Return True if another interface on *device* already uses *name*. - - Pre-checks the per-device interface-name uniqueness NetBox enforces so a - rename/create that would collide is skipped cleanly instead of raising - mid-transaction. (VC-wide uniqueness is not pre-checked here; full_clean() - remains the authoritative validator for that rarer cross-member case.) - """ - from dcim.models import Interface - - qs = Interface.objects.filter(device=device, name=name) - if exclude_pk is not None: - qs = qs.exclude(pk=exclude_pk) - return qs.exists() - - -def _record_skip(conflicts, device, current_name, attempted_name, interface_pk=None): - """Append a skipped rename to *conflicts* when the caller is collecting them. - - The caller has already logged why it skipped; this only lets the interactive Apply view report - how many renames were dropped. - """ - if conflicts is not None: - conflicts.append( - { - "device": str(device), - "current_name": current_name, - "attempted_name": attempted_name, - "interface_pk": interface_pk, - } - ) - - -def _record_conflict(conflicts, device, current_name, attempted_name, interface_pk=None): - """Log a name collision at WARNING and record it as a skipped rename. - - Collisions are expected during automatic renaming (module install, type - change, VC change) when the computed name is already taken on the device; - they must never abort the batch, so callers skip the rename and carry on. - """ - logger.warning( - "Interface name %r already exists on device %s — skipping rename of %r → %r", - attempted_name, - device, - current_name, - attempted_name, - ) - _record_skip(conflicts, device, current_name, attempted_name, interface_pk) - - -# What a family-aware rename did, so the children can act on their parent's outcome rather than on -# its computed target name (which says nothing about whether the parent actually took it). -_RENAMED = "renamed" -_UNCHANGED = "unchanged" -_COLLISION = "collision" -_ERROR = "error" - -_RenameResult = namedtuple("_RenameResult", ("target_name", "outcome", "count")) - - -def _rename_in_place(iface, new_name, device, conflicts): - """Rename *iface* to *new_name*; return 1 if renamed, 0 if no-op or collision.""" - if new_name == iface.name: - return 0 - if _name_exists_on_device(device, new_name, exclude_pk=iface.pk): - _record_conflict(conflicts, device, iface.name, new_name, iface.pk) - return 0 - iface.name = new_name - iface.full_clean() - iface.save() - return 1 - - -def _rename_for_family(iface, new_name, device, conflicts): # pragma: no cover - requires channelization support - """Rename *iface* as part of a family walk, reporting the outcome instead of raising. - - The save runs in its own savepoint so an unexpected failure on one member leaves the - surrounding transaction usable: family processing is best-effort per interface, and only a - failed *parent* stops the rest of its family. - """ - if new_name == iface.name: - return _RenameResult(new_name, _UNCHANGED, 0) - old_name = iface.name - try: - with transaction.atomic(): - renamed = _rename_in_place(iface, new_name, device, conflicts) - except (ValueError, ValidationError, IntegrityError): - logger.exception("Failed to rename interface %r → %r on device %s; skipping.", old_name, new_name, device) - iface.name = old_name - return _RenameResult(new_name, _ERROR, 0) - return _RenameResult(new_name, _RENAMED, 1) if renamed else _RenameResult(new_name, _COLLISION, 0) - - -def _restore_deferred_channel_names(reconciliations, db_alias): # pragma: no cover - channelization only - """Restore plugin-owned names that NetBox's parent cascade changed after commit.""" - from dcim.models import Interface - - child_pks = [child_pk for child_pk, _final_name, _cascade_name in reconciliations] - with transaction.atomic(using=db_alias): - children = Interface.objects.using(db_alias).select_for_update().select_related("device").in_bulk(child_pks) - for child_pk, final_name, cascade_name in reconciliations: - child = children.get(child_pk) - if child is None or child.name == final_name: - continue - if child.name != cascade_name: - logger.warning( - "Channel interface %s changed to unexpected name %r before deferred reconciliation; " - "leaving it unchanged.", - child_pk, - child.name, - ) - continue - previous_name = child.name - try: - with transaction.atomic(using=db_alias): - child.name = final_name - child.full_clean() - child.save(using=db_alias) - except (ValueError, ValidationError, IntegrityError): - child.name = previous_name - logger.exception( - "Failed to restore channel interface %s from NetBox's deferred name %r to %r; skipping.", - child_pk, - cascade_name, - final_name, - ) - - -def _preserve_names_across_parent_cascade(parent, parent_before, final_names): # pragma: no cover - """Run after NetBox's deferred cascade when a rule intentionally keeps old-parent child names.""" - if parent.name == parent_before: - return - - reconciliations = [] - for child, final_name in final_names: - old_conventional_name = f"{parent_before}:{child.channel_id}" - cascade_name = f"{parent.name}:{child.channel_id}" - if final_name == old_conventional_name and final_name != cascade_name: - reconciliations.append((child.pk, final_name, cascade_name)) - if not reconciliations: - return - - reconciliations = tuple(reconciliations) - db_alias = parent._state.db - transaction.on_commit( - lambda: _restore_deferred_channel_names(reconciliations, db_alias), - using=db_alias, - ) - - -def _rename_channel_children(parent, parent_before, children, module, conflicts): # pragma: no cover - see above - """Carry the parent's new name onto its channel subinterfaces; return how many were renamed.""" - count = 0 - for child, target in _child_target_names(children, parent_before, parent.name, module): - if target is None: - logger.warning( - "Cannot derive a name for channel interface %r from parent %r; leaving it unchanged.", - child.name, - parent.name, - ) - continue - count += _rename_for_family(child, target, module.device, conflicts).count - return count - - -def _apply_simple_rule_to_family(rule, parent, children, variables, module, conflicts): # pragma: no cover - """Rename a channelized family in lockstep with its parent; return the count renamed. - - The children act on the parent's *outcome*, never on its computed target: a parent that - collided or failed to save leaves the whole family untouched, while a parent that already - carries the right name still lets a stale child be repaired. - """ - parent_before = parent.name - new_name = evaluate_name_template(rule.name_template, {**variables, "base": parent.name}) - result = _rename_for_family(parent, new_name, module.device, conflicts) - if result.outcome in (_COLLISION, _ERROR): - logger.debug("Family of %r left unchanged: the parent could not be renamed to %r.", parent.name, new_name) - return result.count - return result.count + _rename_channel_children(parent, parent_before, children, module, conflicts) - - -def _rename_family_parent(rule, parent, variables, module, conflicts): # pragma: no cover - see above - """Rename an existing family's parent per the rule's parent template; return its rename outcome. - - Only a channelized rule that names its parent touches it — a flat rule, or a blank parent - template, leaves the parent the name it already has. - """ - if not (_is_channelized_rule(rule) and rule.parent_name_template): - return _RenameResult(parent.name, _UNCHANGED, 0) - target = evaluate_name_template(rule.parent_name_template, {**variables, "base": parent.name}) - return _rename_for_family(parent, target, module.device, conflicts) - - -def _apply_breakout_rule_to_family(rule, parent, children, variables, module, conflicts): # pragma: no cover - """Rename an already-channelized family; return the count renamed, or None when skipped. - - Nothing is ever created here: the channels the rule describes are rows NetBox already models, - so a breakout rule renames them in place. The parent is renamed only when the rule builds - channelized families and names their parent; the channels' ``{base}`` stays the parent's name - as it was before that rename. A rule whose channel count disagrees with the hardware is a - modelling mismatch — the family is skipped whole rather than renamed into a shape it does not - have, and a parent that could not take its name stops the family the same way a simple rule's - does. - - Every child's name is computed before the first save, so a template that only fails on a later - channel (channel-dependent arithmetic) aborts the family untouched instead of half renaming it. - """ - if getattr(parent, "channels", None) != rule.channel_count: - logger.warning( - "Interface %r provides %s channels but rule '%s' defines %s; skipping the family.", - parent.name, - getattr(parent, "channels", None), - rule, - rule.channel_count, - ) - return None - base_name = parent.name - targets = [ - ( - child, - evaluate_name_template( - rule.name_template, - {**variables, "base": base_name, "channel": str(rule.channel_start + child.channel_id - 1)}, - ), - ) - for child in children - ] - result = _rename_family_parent(rule, parent, variables, module, conflicts) - if result.outcome in (_COLLISION, _ERROR): - logger.debug( - "Family of %r left unchanged: the parent could not be renamed to %r.", base_name, result.target_name - ) - return result.count - count = result.count - final_names = [] - for child, new_name in targets: - previous_name = child.name - child_result = _rename_for_family(child, new_name, module.device, conflicts) - count += child_result.count - final_name = new_name if child_result.outcome in (_RENAMED, _UNCHANGED) else previous_name - final_names.append((child, final_name)) - _preserve_names_across_parent_cascade(parent, base_name, final_names) - return count - - -def _is_channelized_rule(rule): - """Return True when *rule* asks for the channelized topology instead of flat sibling interfaces.""" - return rule.breakout_mode == BreakoutModeChoices.CHANNELIZED - - -def _channelized_family_names(rule, base_name, variables): # pragma: no cover - requires channelization support - """Return ``(parent_name, [(channel_id, name), ...])`` for the family *rule* builds on *base_name*. - - ``{base}`` is the base interface's current name for the parent and every channel; ``{channel}`` - is ``channel_start + channel_id - 1``. A blank parent template leaves the base's name alone. - Takes the name rather than the interface so prediction can reuse it without a row to point at. - """ - family_vars = {**variables, "base": base_name} - parent_name = base_name - if rule.parent_name_template: - parent_name = evaluate_name_template(rule.parent_name_template, family_vars) - channels = [ - ( - channel_id, - evaluate_name_template( - rule.name_template, {**family_vars, "channel": str(rule.channel_start + channel_id - 1)} - ), - ) - for channel_id in range(1, rule.channel_count + 1) - ] - return parent_name, channels - - -def _has_flat_expansion(module): # pragma: no cover - requires channelization support - """Return True when *module* carries more interfaces than its module type's templates describe. - - A flat breakout leaves N-1 rows beyond the templates, so the surplus is the structural mark of a - family an earlier apply installed. Counting templates rather than their resolved names keeps - two templates that resolve to the same string from reading as one. - """ - from dcim.models import Interface, InterfaceTemplate - - templates = InterfaceTemplate.objects.filter(module_type_id=module.module_type_id).count() - return Interface.objects.filter(module=module).count() > templates - - -def _first_taken_name(device, names, exclude_pk): # pragma: no cover - requires channelization support - """Return the first of *names* already used by another interface on *device*, or None.""" - for name in names: - if _name_exists_on_device(device, name, exclude_pk=exclude_pk): - return name - return None - - -def _build_channelized_family(rule, base, variables, module, conflicts): # pragma: no cover - see above - """Turn a plain base interface into a channelized family; return how many rows it changed. - - The whole family is preflighted before anything is written: a module that already carries a flat - family, or a single occupied name — the parent's or any channel's — leaves the base exactly as - it was instead of half converting it. - """ - from dcim.choices import InterfaceTypeChoices - from dcim.models import Interface - - device = module.device - base_name = base.name - parent_name, channels = _channelized_family_names(rule, base_name, variables) - if _has_flat_expansion(module): - # Converting one sibling into a parent would strand the others beside the new family. - logger.warning( - "Module %s already carries a flat breakout family; rule '%s' will not convert interface " - "%r into the channelized parent %r — converting an installed family is a separate, " - "explicit operation. Skipping.", - module, - rule, - base.name, - parent_name, - ) - _record_skip(conflicts, device, base.name, parent_name, base.pk) - return 0 - blocker = _first_taken_name(device, [parent_name, *(name for _, name in channels)], base.pk) - if blocker is not None: - logger.warning( - "Cannot build the channelized family for interface %r on device %s: %r is already taken; skipping.", - base.name, - device, - blocker, - ) - _record_skip(conflicts, device, base.name, blocker, base.pk) - return 0 - - count = 0 - with transaction.atomic(): - created_channels = [] - base.channels = rule.channel_count - if parent_name != base.name: - base.name = parent_name - count += 1 - base.full_clean() - base.save() - for channel_id, name in channels: - channel = Interface( - device=device, - module=module, - name=name, - type=InterfaceTypeChoices.TYPE_CHANNEL, - parent=base, - channel_id=channel_id, - enabled=base.enabled, - ) - channel.full_clean() - channel.save() - created_channels.append((channel, name)) - count += 1 - _preserve_names_across_parent_cascade(base, base_name, created_channels) - return count - - -def _apply_channelized_rule(rule, base, variables, module, conflicts): - """Build the channelized family *rule* describes on a plain base interface. - - Returns None where NetBox cannot model channels: the rule describes a topology this release has - no rows for, and building a flat family instead would silently give the operator another one. - """ - if not supports_channelization(): - logger.warning( - "Rule '%s' builds a channelized family, which this NetBox release cannot model; " - "leaving interface %r unchanged.", - rule, - base.name, - ) - return None - return _build_channelized_family(rule, base, variables, module, conflicts) # pragma: no cover - see above - - -def _apply_rule_with_family(rule, iface, children, variables, module, conflicts): - """Apply *rule* to *iface*, carrying its channel subinterfaces along. - - Returns the number of interfaces renamed/created, or None when a channelized family was - skipped for a structural reason. Interfaces that own no family take the plain path unchanged. - """ - if rule.channel_count > 0 and (_is_channelized_parent(iface) or children): # pragma: no cover - return _apply_breakout_rule_to_family(rule, iface, children, variables, module, conflicts) - if children: # pragma: no cover - requires channelization support - return _apply_simple_rule_to_family(rule, iface, children, variables, module, conflicts) - if rule.channel_count > 0 and _is_channelized_rule(rule): - return _apply_channelized_rule(rule, iface, variables, module, conflicts) - return _apply_rule_to_interface(rule, iface, {**variables, "base": iface.name}, module, conflicts=conflicts) - - -def _create_channel(iface, module, new_name, device, conflicts): - """Create a breakout channel interface *new_name*; return 1 if created, else 0. - - Silently skips when this module already has the channel (idempotent - re-apply); records a conflict when *new_name* is taken by a different - interface on the device. - """ - from dcim.models import Interface - - if Interface.objects.filter(module=module, name=new_name).exists(): - return 0 # idempotent: channel already created on this module - if _name_exists_on_device(device, new_name): - _record_conflict(conflicts, device, iface.name, new_name, iface.pk) - return 0 - breakout_iface = Interface( - device=device, - module=module, - name=new_name, - type=iface.type, - enabled=iface.enabled, - ) - breakout_iface.full_clean() - breakout_iface.save() - return 1 - - -def _apply_rule_to_interface(rule, iface, variables, module, conflicts=None): - """Apply a single rule to an interface, handling breakout channels. - - All saves are wrapped in a transaction so a failure mid-breakout rolls - back any partially created interfaces. A computed name that already exists - on the device is skipped (logged, and recorded in *conflicts* when a list - is passed) instead of raising — so automatic renaming (module install, - module-type change, VC change) never aborts the rest of the batch on a - name collision. - - Returns the number of interfaces renamed/created. - """ - count = 0 - device = module.device - - with transaction.atomic(): - if rule.channel_count > 0: - # Breakout: rename base interface and create additional channel interfaces - for ch in range(rule.channel_count): - variables["channel"] = str(rule.channel_start + ch) - new_name = evaluate_name_template(rule.name_template, variables) - if ch == 0: - count += _rename_in_place(iface, new_name, device, conflicts) - else: - count += _create_channel(iface, module, new_name, device, conflicts) - else: - # Simple rename (converter offset, platform naming, etc.) - new_name = evaluate_name_template(rule.name_template, variables) - count += _rename_in_place(iface, new_name, device, conflicts) - - return count - - -def _find_channel_base(rule, ifaces, variables): - """Find the best 'base' interface for a channel rule on a single module. - - Prefers an interface whose current name already equals the expected ch=0 name - (i.e. it has already been renamed to channel 0 and is safe to re-process). - Falls back to the first interface (alphabetically) so that on first apply, - the template-created base interface becomes channel 0. - - This ensures apply_rule_to_existing / find_interfaces_for_rule call - _apply_rule_to_interface exactly ONCE per module for channel rules, preventing - duplicate-name IntegrityErrors when channels already exist. - """ - if not ifaces: - return None - for iface in ifaces: - vars_copy = dict(variables) - vars_copy["base"] = iface.name - vars_copy["channel"] = str(rule.channel_start) # ch=0 - try: - ch0_name = evaluate_name_template(rule.name_template, vars_copy) - if iface.name == ch0_name: - return iface - except ValueError: - pass - return ifaces[0] - - -def _matching_moduletype_pks(module_type_pattern): - """Return PKs of ModuleTypes whose model name matches the given regex pattern. - - Raises ValueError for invalid regex patterns, mirroring evaluate_name_template's - error-handling convention so callers can treat both as ValueError. - """ - from dcim.models import ModuleType - - try: - compiled = re.compile(module_type_pattern) - except re.error as exc: - raise ValueError(f"Invalid module_type_pattern regex '{module_type_pattern}': {exc}") from exc - return [mt.pk for mt in ModuleType.objects.only("pk", "model") if compiled.fullmatch(mt.model)] - - -def has_applicable_interfaces(rule) -> bool: - """Check whether applying this rule right now would rename at least one interface. - - Calls find_interfaces_for_rule(limit=1) to determine if any currently installed - interface would receive a new name. Returns False when: - - no matching modules/interfaces are installed, OR - - all matching interfaces are already correctly named. - - This is more expensive than a plain EXISTS query but ensures the Applicable - column in the Apply Rules list accurately reflects "would something change?" - rather than the misleading "do interfaces exist?". - """ - try: - results, _ = find_interfaces_for_rule(rule, limit=1) - return len(results) > 0 - except (ValueError, re.error): - return False - - -def _build_module_qs(rule): - """Return a Module queryset filtered to the rule's scope (module type, parent, device, platform). - - Shared by ``find_interfaces_for_rule`` and ``apply_rule_to_existing`` to avoid - duplicating the filtering logic. + Shared by ``find_interfaces_for_rule`` and ``apply_rule_to_existing`` to avoid + duplicating the filtering logic. """ from dcim.models import Module @@ -1832,162 +561,105 @@ def _build_module_qs(rule): return qs -def _name_detail(name, role, channel_id=None) -> dict: - """Describe one previewed name so the UI can render a family as a family. - - *role* is ``interface`` (a plain rename), ``parent`` (the family's physical interface) or - ``channel``; *channel_id* is the parent channel a channel name is bound to, when known. - """ - return {"name": name, "role": role, "channel_id": channel_id} - - -def _family_entry(module, parent, details, children) -> dict | None: - """Build a family preview entry from per-name *details*, or None when nothing would change. - - The entry stays keyed on the parent — the PK the Apply view submits — and lists the family's - names in ``new_names``, so the existing template loop keeps working unchanged. - """ - if [detail["name"] for detail in details] == [parent.name, *(child.name for child in children)]: - return None - return { - "module": module, - "interface": parent, - "current_name": parent.name, - "new_names": [detail["name"] for detail in details], - "name_details": details, - } +_PREVIEW_ROLES = { + family_ops.MemberRole.PARENT: "parent", + family_ops.MemberRole.CHANNEL: "channel", +} -def _evaluate_plain_interface(rule, module, iface, variables, children=()) -> dict | None: - """Return a result dict if *iface* or one of its channels would be renamed by *rule*, else None. +def _member_detail(plan, member) -> family_ops.PlannedName: + """Describe one planned member so the UI can render a family as a family. - A channelized parent is previewed together with its channels, so the Apply page shows the whole - family behind the one PK it submits. + A flat family's members are the channels a breakout rule spells out; a plan that holds only + one of them is a plain rename, not a family. """ - vars_copy = {**variables, "base": iface.name} - try: - new_name = evaluate_name_template(rule.name_template, vars_copy) - except ValueError as exc: - new_name = f"" - if children: # pragma: no cover - requires channelization support - return _family_entry(module, iface, _lockstep_details(new_name, iface, children, module), children) - return _family_entry(module, iface, [_name_detail(new_name, "interface")], ()) - + role = _PREVIEW_ROLES.get(member.role) or ("channel" if len(plan.members) > 1 else "interface") + return family_ops.PlannedName(member.target_name, role, member.channel_id) -def _lockstep_details(new_name, parent, children, module) -> list: # pragma: no cover - see above - """Per-name preview details for a family renamed in lockstep with its parent. - A channel whose suffix cannot be derived previews as unchanged — the same thing the apply path - does with it. - """ - details = [_name_detail(new_name, "parent")] - for child, target in _child_target_names(children, parent.name, new_name, module): - details.append(_name_detail(child.name if target is None else target, "channel", child.channel_id)) - return details +def _plan_details(plan) -> list: + """Describe every name the plan intends, or the error that stopped it from naming them.""" + if plan.precondition_status != family_ops.FamilyStatus.FAILED: + return [_member_detail(plan, member) for member in plan.members] + root = _member_detail(plan, plan.members[0]) + return [family_ops.PlannedName(f"", root.role, root.channel_id)] -def _channelized_family_entry(rule, module, parent, children, variables) -> dict | None: # pragma: no cover - """Preview a breakout rule against an already-channelized family. +def _plan_changes_names(plan, existing_names) -> bool: + """Return whether the planned family would rename or create anything. - Nothing is created — only the existing channels are renamed, plus the parent when the rule - names one — so a family whose channel count disagrees with the rule previews as no change at - all. + A plan that renames members compares intent with the names they carry now. A plan that builds + a family out of one base compares intent with the names the module already holds, so a family + an earlier apply already installed previews as no change. """ - if getattr(parent, "channels", None) != rule.channel_count: - return None - parent_name = parent.name - if _is_channelized_rule(rule) and rule.parent_name_template: - try: - parent_name = evaluate_name_template(rule.parent_name_template, {**variables, "base": parent.name}) - except ValueError as exc: - parent_name = f"" - details = [_name_detail(parent_name, "parent")] - for child in children: - channel = str(rule.channel_start + child.channel_id - 1) - try: - new_name = evaluate_name_template( - rule.name_template, {**variables, "base": parent.name, "channel": channel} - ) - except ValueError as exc: - new_name = f"" - details.append(_name_detail(new_name, "channel", child.channel_id)) - return _family_entry(module, parent, details, children) - - -def _channelized_family_preview(rule, module, base, variables) -> dict | None: # pragma: no cover - see below - """Describe the channelized family a rule would build on a plain base interface.""" - try: - parent_name, channels = _channelized_family_names(rule, base.name, variables) - except ValueError as exc: - return _family_entry(module, base, [_name_detail(f"", "parent")], ()) - details = [_name_detail(parent_name, "parent")] - details.extend(_name_detail(name, "channel", channel_id) for channel_id, name in channels) - return _family_entry(module, base, details, ()) + if plan.base_name is None: # pragma: no cover - requires channelization support + return plan.target_names != plan.source_names + return plan.target_names[0] != plan.base_name or any( + target_name not in existing_names for target_name in plan.target_names + ) -def _channelized_creation_entry(rule, module, bases, variables) -> dict | None: - """Return the preview entry for the family a channelized rule would build, or None for none. +def _plan_entry(module, plan, interface, existing_names) -> dict | None: + """Build the preview entry for one family plan, or None when it would change nothing. - A release that cannot model channels previews nothing, because the apply path builds nothing - there either; neither does a module whose flat family the apply path refuses to convert. + The entry stays keyed on the interface the Apply view submits, and lists the family's names in + ``new_names``, so the existing template loop keeps working unchanged. A plan the live topology + blocks previews nothing, because the apply path would build nothing either. """ - if not supports_channelization(): + failed = plan.precondition_status == family_ops.FamilyStatus.FAILED + if plan.precondition_status is not None and not failed: return None - if _has_flat_expansion(module): # pragma: no cover - requires channelization support + if not failed and not _plan_changes_names(plan, existing_names): return None - return _channelized_family_preview( # pragma: no cover - requires channelization support - rule, module, _find_channel_base(rule, bases, variables), variables - ) - + details = _plan_details(plan) + return { + "module": module, + "interface": interface, + "current_name": interface.name, + "new_names": [detail.name for detail in details], + "name_details": details, + } -def _channel_rule_entries(rule, module, bases, children_by_parent, variables) -> list: - """Return the preview entries a channel rule produces for one module. - A module whose base is already channelized previews per family (renames only); a channelized - rule on a plain base previews the family it would build; anything else keeps the flat breakout - preview of one entry per module. - """ - families = [base for base in bases if _is_channelized_parent(base)] - if families: # pragma: no cover - requires a NetBox that models channelization - entries = [ - _channelized_family_entry(rule, module, parent, children_by_parent.get(parent.pk, ()), variables) - for parent in families - ] - return [entry for entry in entries if entry] - if _is_channelized_rule(rule): - entry = _channelized_creation_entry(rule, module, bases, variables) - return [entry] if entry else [] - entry = _channel_rule_entry(rule, module, bases, variables) - return [entry] if entry else [] - - -def _channel_rule_entry(rule, module, ifaces, variables) -> dict | None: - """Return a result dict if the channel rule would change any name for this module, else None.""" - base_iface = _find_channel_base(rule, ifaces, variables) - if base_iface is None: - return None - vars_copy = {**variables, "base": base_iface.name} - expected_names = [] - try: - for ch in range(rule.channel_count): - expected_names.append( - evaluate_name_template(rule.name_template, {**vars_copy, "channel": str(rule.channel_start + ch)}) - ) - except ValueError as exc: - expected_names = [f""] - existing_names = {i.name for i in ifaces} - # Report if any channel name is missing or the base itself needs renaming - if any(n not in existing_names for n in expected_names) or ( - expected_names and expected_names[0] != base_iface.name - ): - return { - "module": module, - "interface": base_iface, - "current_name": base_iface.name, - "new_names": expected_names, - "name_details": [_name_detail(name, "channel") for name in expected_names], - } - return None +def _plan_root_name(plan) -> str: + """Return the name of the interface a plan is submitted through.""" + return plan.base_name if plan.base_name is not None else plan.members[0].source_name + + +def _preview_plans(rule, plan_set) -> list: + """Return the plans this preview reports. + + A breakout rule on a module that already models channelized families renames those families and + adds none beside them. Anywhere else it builds one family per base, and two bases that intend + the same names are the one family an earlier apply already started, so it is offered once: the + same family the apply path would build. + """ + if rule.channel_count <= 0: + return list(plan_set.plans) + installed = [plan for plan in plan_set.plans if plan.base_name is None] + if installed: # pragma: no cover - requires a NetBox that models channelization + return installed + creations = [plan for plan in plan_set.plans if plan.base_name is not None] + kept = family_targets.one_family_per_name_set([(plan.base_name, plan.target_names) for plan in creations]) + return [creations[index] for index in kept] + + +def _process_module(rule, module, ifaces, variables, limit, results, module_qs, processed_pks): + """Preview one module from its family plans. Returns (checked_count, should_stop).""" + plan_set = family_ops.plan_prospective_families(module, rule, variables, family_ops.describe_interfaces(ifaces)) + checked = len(plan_set.plans) + if not checked: + return 0, False + rows_by_name = {iface.name: iface for iface in ifaces} + existing_names = frozenset(rows_by_name) + for plan in _preview_plans(rule, plan_set): + entry = _plan_entry(module, plan, rows_by_name[_plan_root_name(plan)], existing_names) + if entry is None: + continue + results.append(entry) + if limit is not None and len(results) >= limit: + return checked + _count_remaining_interfaces(module_qs, processed_pks), True + return checked, False def _count_remaining_interfaces(module_qs, processed_pks) -> int: @@ -2000,35 +672,6 @@ def _count_remaining_interfaces(module_qs, processed_pks) -> int: return qs.count() -def _process_channel_module(rule, module, ifaces, variables, limit, results, module_qs, processed_pks): - """Process one module for a channel rule. Returns (checked_count, should_stop).""" - bases, children_by_parent = _partition_families(ifaces) - checked = len(bases) - if not bases: - return checked, False - for entry in _channel_rule_entries(rule, module, bases, children_by_parent, variables): - results.append(entry) - if limit is not None and len(results) >= limit: - return checked + _count_remaining_interfaces(module_qs, processed_pks), True - return checked, False - - -def _process_plain_module(rule, module, ifaces, variables, limit, results, module_qs, processed_pks): - """Process one module for a plain (non-channel) rule. Returns (checked_count, should_stop).""" - bases, children_by_parent = _partition_families(ifaces) - checked = 0 - for iface_idx, iface in enumerate(bases): - checked += 1 - entry = _evaluate_plain_interface(rule, module, iface, variables, children_by_parent.get(iface.pk, ())) - if entry: - results.append(entry) - if limit is not None and len(results) >= limit: - checked += len(bases) - (iface_idx + 1) - checked += _count_remaining_interfaces(module_qs, processed_pks) - return checked, True - return checked, False - - def find_interfaces_for_rule(rule, limit=None): """Find interfaces that would be renamed by applying the given rule retroactively. @@ -2042,7 +685,7 @@ def find_interfaces_for_rule(rule, limit=None): "interface": Interface instance, "current_name": str, "new_names": list[str], # one entry per channel, or single-element - "name_details": list[dict], # {"name", "role", "channel_id"} per new_names entry + "name_details": list[PlannedName], # name, role and channel id per new_names entry } Only includes entries where at least one new_name differs from current_name. @@ -2062,8 +705,6 @@ def find_interfaces_for_rule(rule, limit=None): "module_bay", "module_bay__parent", ) - process_fn = _process_channel_module if rule.channel_count > 0 else _process_plain_module - # Batch-load all interfaces for matching modules to avoid N+1 queries. ifaces_by_module = defaultdict(list) for iface in Interface.objects.filter(module__in=module_qs).order_by("module_id", "name"): @@ -2076,7 +717,7 @@ def find_interfaces_for_rule(rule, limit=None): processed_pks.add(module.pk) variables = build_variables(module.module_bay, device=module.device) ifaces = ifaces_by_module.get(module.pk, []) - checked, stop = process_fn(rule, module, ifaces, variables, limit, results, module_qs, processed_pks) + checked, stop = _process_module(rule, module, ifaces, variables, limit, results, module_qs, processed_pks) total_checked += checked if stop: return results, total_checked @@ -2084,128 +725,37 @@ def find_interfaces_for_rule(rule, limit=None): return results, total_checked -def _apply_channel_rule_to_module(rule, module, ifaces, variables, id_set, conflicts): - """Apply a channel rule to one module via its base interface; return the rename count. - - On a module whose base is already channelized the rule renames the existing channels, once per - family. Otherwise the rule is processed ONCE per module (not per interface) so existing - channel names are not re-created. An unexpected failure (e.g. a save race) is logged and - skipped so it never aborts the surrounding batch. - """ - bases, children_by_parent = _partition_families(ifaces) - if not bases: - return 0 - families = [base for base in bases if _is_channelized_parent(base)] - if families: # pragma: no cover - requires a NetBox that models channelization - count = 0 - for parent in families: - if id_set is not None and parent.pk not in id_set: - continue - count += _apply_family(rule, parent, children_by_parent.get(parent.pk, ()), variables, module, conflicts) - return count - base_iface = _find_channel_base(rule, bases, variables) - if id_set is not None and base_iface.pk not in id_set: - return 0 - vars_copy = dict(variables) - vars_copy["base"] = base_iface.name - try: - if _is_channelized_rule(rule): - return _apply_channelized_rule(rule, base_iface, variables, module, conflicts) or 0 - return _apply_rule_to_interface(rule, base_iface, vars_copy, module, conflicts=conflicts) - except (ValueError, ValidationError, IntegrityError): - logger.exception( - "Failed to apply channel rule '%s' to module '%s' (id=%s); skipping.", - rule, - module, - module.pk, +def _batch_modules(rule): + """Return the rule's modules with every relation planning and template resolution dereference.""" + return list( + _build_module_qs(rule).select_related( + "module_type", + "device__device_type", + "device__platform", + *family_template_names.BAY_CHAIN_RELATIONS, ) - return 0 - - -def _apply_family(rule, iface, children, variables, module, conflicts): - """Apply *rule* to one base interface and its channels, logging (never raising) on failure.""" - try: - return _apply_rule_with_family(rule, iface, children, variables, module, conflicts) or 0 - except (ValueError, ValidationError, IntegrityError): - logger.exception( - "Failed to apply rule '%s' to interface '%s' (id=%s); skipping.", - rule, - iface.name, - iface.pk, - ) - return 0 - - -def _apply_plain_rule_to_module(rule, module, ifaces, variables, id_set, conflicts): - """Apply a non-channel rule to each selected interface on one module; return the rename count. - - Each base interface is independent: an unexpected failure on one is logged and skipped so the - rest of the module (and batch) still process. Channel subinterfaces are not selectable on - their own — they are renamed only as part of the family whose parent was selected. - """ - bases, children_by_parent = _partition_families(ifaces) - count = 0 - for iface in bases: - if id_set is not None and iface.pk not in id_set: - continue - count += _apply_family(rule, iface, children_by_parent.get(iface.pk, ()), variables, module, conflicts) - return count + ) -def apply_rule_to_existing(rule, limit=None, interface_ids=None, conflicts=None): +def apply_rule_to_existing(rule, limit=None, interface_ids=None) -> family_ops.BatchOutcome: """Apply a rule retroactively to all matching installed modules. - Unlike apply_interface_name_rules(), this does not skip already-renamed - interfaces — it re-evaluates every interface on each matching module. - - For channel rules (channel_count > 0), each module is processed as a single - unit using _find_channel_base() to pick the base interface. Calling - _apply_rule_to_interface for every interface in the module would produce - duplicate-name IntegrityErrors when channel interfaces already exist. + Unlike apply_interface_name_rules(), this does not skip already-renamed interfaces: it plans + every family each matching module carries or would gain, and executes each in its own + transaction, so one blocked family costs the batch only that family. - If *interface_ids* is provided (list/set of Interface PKs), only those - interfaces are processed; all others are skipped. For channel rules the - base interface PK is used as the selector. An empty *interface_ids* - collection returns 0 immediately without touching the database. Selecting a - channelized parent brings its channel subinterfaces along; selecting a channel - subinterface on its own does nothing, because it is not an independent candidate. + If *interface_ids* is provided (list/set of Interface PKs), only the families those interfaces + reach are applied; an empty collection touches the database not at all. Selecting a + channelized parent brings its channel subinterfaces along; selecting a channel subinterface on + its own does nothing, because it is not an independent candidate. If *limit* is set the batch + stops after the module that reached that many changed interfaces. - If *conflicts* is a list, each interface skipped because its target name is - already taken on the device is appended to it (and logged) — letting the - caller report how many renames were dropped. Collisions never raise. - - Returns the number of interfaces renamed/created. + Returns the batch outcome: one explicit family result per family it planned. """ - from dcim.models import Interface - id_set = frozenset(interface_ids) if interface_ids is not None else None - if id_set is not None and not id_set: - return 0 - - if not rule.enabled: - return 0 - - module_qs = _build_module_qs(rule) - - # Batch-load interfaces to avoid N+1 queries in the module loop. - ifaces_by_module = defaultdict(list) - for iface in Interface.objects.filter(module__in=module_qs).order_by("module_id", "name"): - ifaces_by_module[iface.module_id].append(iface) - - count = 0 - for module in module_qs.select_related("module_bay", "module_type", "device", "device__virtual_chassis"): - variables = build_variables(module.module_bay, device=module.device) - ifaces = ifaces_by_module.get(module.pk, []) - - if rule.channel_count > 0: - count += _apply_channel_rule_to_module(rule, module, ifaces, variables, id_set, conflicts) - else: - count += _apply_plain_rule_to_module(rule, module, ifaces, variables, id_set, conflicts) - - if limit is not None and count >= limit: - return count - - return count + if not rule.enabled or (id_set is not None and not id_set): + return family_ops.BatchOutcome(families=()) + return family_ops.apply_rule_to_modules(rule, _batch_modules(rule), selected_pks=id_set, limit=limit) # --------------------------------------------------------------------------- @@ -2215,400 +765,28 @@ def apply_rule_to_existing(rule, limit=None, interface_ids=None, conflicts=None) # with N channel subinterfaces. Converting one rewrites rows an operator owns — cables, addresses, # tags — so it is never a side effect of applying a rule: the operator confirms it per family. -# The ch-0 row, the names its family carries now, and the names it would carry once converted. -_ConversionFamily = namedtuple("_ConversionFamily", ("module", "base", "current_names", "parent_name", "channel_names")) +def find_convertible_families(rule, limit=None) -> family_ops.ConversionPreview: + """Return the preview of the flat families *rule* could convert, convertible or not. -def _conversion_offered(rule): - """Return True when *rule* describes a topology an installed flat family could be converted into. - - A disabled rule renames nothing on any apply path, so it converts nothing either. A flat family - has no parent row — its ch-0 interface *is* the base — so without a parent name there is nowhere - for that base to go, and the conversion is not offered at all. - """ - return rule.enabled and _is_channelized_rule(rule) and rule.channel_count > 0 and bool(rule.parent_name_template) - - -def _base_marked_ch0_name(rule, variables): # pragma: no cover - requires channelization support - """Return *rule*'s escaped ch-0 output with ``{base}`` left as a sentinel, or None when it cannot be. - - Evaluated once per rule: the sentinel stands in for ``{base}`` so a raw matcher can be spliced - over it afterwards. - """ - try: - evaluated = evaluate_name_template( - rule.name_template, {**variables, "base": _BASE_SENTINEL, "channel": str(rule.channel_start)} - ) - except (ValueError, TypeError): - # A {base} inside an arithmetic expression cannot take a non-numeric stand-in — see the docs. - logger.debug( - "Rule '%s' evaluates {base} arithmetically, so a base predating a virtual-chassis " - "position change cannot be recovered from its output names; not offering its families.", - rule, - ) - return None - if _BASE_SENTINEL not in evaluated: - return None # the rule's output does not carry the base, so no drift reached it - return re.escape(evaluated) - - -def _recovered_bases(rule, interfaces, variables, matchers): # pragma: no cover - channelization only - """Return the historical ``{base}`` values *rule*'s installed families still spell on this module. - - A flat family carries rule-*output* names, so a raw matcher cannot be run against them directly: - it is spliced into the rule's own ch-0 output as a capture instead — a repeated ``{base}`` becomes - a backreference rather than a second group — and the capture yields the base the family was named - with. Conversion rewrites rows an operator owns, so anything ambiguous (one matcher over two - bases, or two templates recovering the same one) yields nothing at all. - """ - marked = _base_marked_ch0_name(rule, variables) - if marked is None: - return [] - head, _, tail = marked.partition(_BASE_SENTINEL) - tail = tail.replace(_BASE_SENTINEL, "(?P=base)") - recovered = defaultdict(int) - for matcher in matchers: - family_pattern = _compile_pattern(f"{head}(?P{matcher.pattern.pattern}){tail}") - if family_pattern is None: - continue - matches = (family_pattern.fullmatch(iface.name) for iface in interfaces) - bases = {match.group("base") for match in matches if match} - if len(bases) == 1: - recovered[bases.pop()] += 1 - return [base for base, claims in recovered.items() if claims == 1] - - -def _family_on(rule, module, by_name, variables, base_name): # pragma: no cover - channelization only - """Return the family *rule* describes on *base_name*, or None when this module carries none.""" - family_vars = {**variables, "base": base_name} - parent_name = evaluate_name_template(rule.parent_name_template, family_vars) - channel_names = [ - evaluate_name_template(rule.name_template, {**family_vars, "channel": str(rule.channel_start + offset)}) - for offset in range(rule.channel_count) - ] - base = by_name.get(channel_names[0]) - if base is None or _is_channel_child(base) or _is_channelized_parent(base): - return None - return _ConversionFamily( - module=module, - base=base, - current_names=[name for name in channel_names if name in by_name], - parent_name=parent_name, - channel_names=channel_names, - ) - - -def _conversion_family(rule, module, interfaces, variables, raw): # pragma: no cover - channelization only - """Return the flat family *rule* would convert on *module*, or None when it carries none. - - Identification is by name: ``name_template`` is evaluated over the rule's channel range against - each raw template name, and the ch-0 name has to still be a plain interface — a family that was - already converted (its ch-0 name now belongs to a channel row) is therefore never offered twice. - A family named before this device's virtual-chassis position changed spells a base no template - resolves to any more, so those bases are recovered from the family's own names and then - identified exactly the same way. + Each candidate names the ch-0 row the confirm form submits, the family's current names, the + names it would carry, and where the ch-0 row's configuration lands. A family beyond *limit* is + never dry-run; the preview reports that one was left unexamined. """ - by_name = {iface.name: iface for iface in interfaces} - for base_name in sorted(raw.names): - family = _family_on(rule, module, by_name, variables, base_name) - if family is not None: - return family - for base_name in _recovered_bases(rule, interfaces, variables, raw.matchers): - family = _family_on(rule, module, by_name, variables, base_name) - if family is not None: - return family - return None + # Only the cheap half of the guard, so a rule that offers no conversion never reads its modules; + # whether this release can hold a family is the family package's call. + if not family_ops.conversion_offered(rule): + return family_ops.ConversionPreview(candidates=()) + return family_ops.preview_rule_conversions(rule, _batch_modules(rule), limit=limit) -def _conversion_families(rule): # pragma: no cover - requires channelization support - """Yield the flat family each module in *rule*'s scope still carries. - - A flat breakout is applied once per module (see ``_apply_channel_rule_to_module``), so a module - carries at most one such family and the conversion mirrors that. - """ - from dcim.models import Interface - - modules = list(_build_module_qs(rule).select_related("module_type", "device", *_BAY_CHAIN_RELATIONS)) - raw_by_module = _raw_names_by_module(modules) - ifaces_by_module = defaultdict(list) - for iface in Interface.objects.filter(module__in=[module.pk for module in modules]).order_by("module_id", "name"): - ifaces_by_module[iface.module_id].append(iface) - for module in modules: - variables = build_variables(module.module_bay, device=module.device) - ifaces = ifaces_by_module.get(module.pk, []) - family = _conversion_family(rule, module, ifaces, variables, raw_by_module[module.pk]) - if family is not None: - yield family - - -def _validate_or_block(iface, role): # pragma: no cover - requires channelization support - """Run NetBox's own validation on *iface*, restating a rejection as this family's blocking reason.""" - try: - iface.full_clean() - except ValidationError as exc: - raise ValidationError(f"{role} {iface.name!r}: {' '.join(exc.messages)}") from exc - - -def _split_ch0_row(rule, family, base): # pragma: no cover - requires channelization support - """Make *base* the family's parent and move its logical identity onto a new channel-1 child. - - Everything an operator configured on the ch-0 row described a channel, not the cage carrying it, - so addresses, VLANs, MTU, description and tags move; custom fields can mean either thing and are - copied. The physical row keeps its pk, cable, type, module link and mark_connected. - """ - from dcim.choices import InterfaceTypeChoices - from dcim.models import Interface - - carried = { - "description": base.description, - "mtu": base.mtu, - "mode": base.mode, - "untagged_vlan_id": base.untagged_vlan_id, - } - tagged_vlans = list(base.tagged_vlans.all()) - tags = list(base.tags.all()) - - base.name = family.parent_name - base.channels = rule.channel_count - base.description = "" - base.mtu = None - base.mode = "" - base.untagged_vlan = None - _validate_or_block(base, "parent") - base.save() # BaseInterface.save() drops the tagged VLANs of an interface that no longer tags - base.tags.clear() - - channel = Interface( - device=family.module.device, - module=family.module, - name=family.channel_names[0], - type=InterfaceTypeChoices.TYPE_CHANNEL, - parent=base, - channel_id=1, - enabled=base.enabled, - custom_field_data=dict(base.custom_field_data or {}), - **carried, - ) - _validate_or_block(channel, "channel") - channel.save() - channel.tagged_vlans.set(tagged_vlans) - channel.tags.set(tags) - base.ip_addresses.all().update(assigned_object_id=channel.pk) - base.fhrp_group_assignments.all().update(interface_id=channel.pk) - - -def _rewrite_family(rule, family): # pragma: no cover - requires channelization support - """Convert *family* in place, raising ValidationError with the reason when it cannot be converted. - - Only what upstream cannot decide for us is checked here: the parent's name has to be free, every - sibling has to be present, a sibling already bound to another parent's channel is not ours to - take, and a cabled sibling cannot become a channel — TYPE_CHANNEL is nonconnectable but not - virtual, so ``Interface.clean()`` accepts a cable on one. Everything else is left to - ``full_clean()`` on each prospective row, which inherits upstream's rules as they grow. - """ - from dcim.choices import InterfaceTypeChoices - from dcim.models import Interface - - device = family.module.device - # Locked for the transaction: the checks below act on this snapshot, and the saves write it back. - by_name = {iface.name: iface for iface in Interface.objects.select_for_update().filter(module=family.module)} - base = by_name.get(family.channel_names[0]) - if base is None or base.pk != family.base.pk: - raise ValidationError( - f"{family.channel_names[0]!r} is gone or replaced: the family changed since it was scanned" - ) - - if _name_exists_on_device(device, family.parent_name, exclude_pk=base.pk): - raise ValidationError(f"the parent name {family.parent_name!r} is already taken on {device}") - - siblings = [] - for channel_id, name in enumerate(family.channel_names[1:], start=2): - sibling = by_name.get(name) - if sibling is None or sibling.pk == base.pk: - raise ValidationError(f"{name!r} is missing: this module carries no complete flat family") - # Rebinding it validates cleanly, so only this check keeps the other family whole. - if _is_channel_child(sibling): - owner = sibling.parent.name if sibling.parent_id else "another parent" - raise ValidationError( - f"{name!r} is already channel {sibling.channel_id} of {owner}; " - f"converting would take it out of that family" - ) - if sibling.cable_id: - raise ValidationError(f"{name!r} has a cable attached; a channel takes its cable from the parent") - siblings.append((channel_id, sibling)) - - _split_ch0_row(rule, family, base) - for channel_id, sibling in siblings: - sibling.type = InterfaceTypeChoices.TYPE_CHANNEL - sibling.parent = base - sibling.channel_id = channel_id - _validate_or_block(sibling, "channel") - sibling.save() - - -def _convert_family(rule, family, commit): # pragma: no cover - requires channelization support - """Convert *family*; return an empty string on success, or the reason it was refused. - - The whole conversion runs inside one savepoint, so a dry run (*commit* False) and a family that - turns out to be unconvertible both leave every row exactly as it was — the rows are re-read here - too, so a rolled-back dry run cannot hand mutated objects back to the caller. - """ - try: - with transaction.atomic(): - _rewrite_family(rule, family) - if not commit: - transaction.set_rollback(True) - except (ValidationError, IntegrityError, ValueError) as exc: - return "; ".join(getattr(exc, "messages", [str(exc)])) - return "" - - -def _conversion_metadata_note(family): # pragma: no cover - requires channelization support - """Return the sentence the Apply page shows about where the ch-0 row's configuration ends up.""" - return ( - f"The addresses, VLANs, MTU, description and tags on {family.base.name} move to the new " - f"channel 1 interface that takes over that name; custom field values are copied. The physical " - f"row keeps its ID and becomes the parent {family.parent_name}, so automation keyed on that " - f"interface ID will address the parent afterwards." - ) - - -def _conversion_verdict(family, reason): # pragma: no cover - requires channelization support - """Describe what converting *family* would do, and why it cannot be done when it cannot.""" - details = [_name_detail(family.parent_name, "parent")] - details.extend( - _name_detail(name, "channel", channel_id) for channel_id, name in enumerate(family.channel_names, start=1) - ) - return { - "module": family.module, - "interface": family.base, - "current_name": family.base.name, - "current_names": family.current_names, - "new_names": [family.parent_name, *family.channel_names], - "name_details": details, - "convertible": not reason, - "reason": reason, - "metadata_note": _conversion_metadata_note(family), - } - - -def find_convertible_families(rule, limit=None) -> tuple: - """Return ``(verdicts, has_more)`` for the flat families *rule* could convert, convertible or not. - - Nothing is written: every family is converted inside a savepoint that is rolled back again, so - each verdict carries the reason NetBox itself would refuse the family rather than a guess at its - rules. Each verdict names the ch-0 row the confirm form submits, the family's current names, - the names it would carry, and where the ch-0 row's configuration lands. - - That dry run is what the scan costs, and a blocked family costs it too, so *limit* caps the - families examined — one verdict each — rather than the convertible ones among them. A family - beyond the limit is never dry-run; *has_more* reports that one was left unexamined. - """ - if not (_conversion_offered(rule) and supports_channelization()): - return [], False - return _find_convertible_families(rule, limit) # pragma: no cover - requires channelization support - - -def _find_convertible_families(rule, limit): # pragma: no cover - requires channelization support - """Dry-run at most *limit* of *rule*'s flat families; see ``find_convertible_families``.""" - verdicts = [] - for family in _conversion_families(rule): - if limit is not None and len(verdicts) >= limit: - return verdicts, True - verdicts.append(_conversion_verdict(family, _convert_family(rule, family, commit=False))) - return verdicts, False - - -def convert_flat_families(rule, base_pks=None, conflicts=None) -> int: - """Convert *rule*'s installed flat families to the channelized topology; return how many. +def convert_flat_families(rule, base_pks=None) -> family_ops.BatchOutcome: + """Convert *rule*'s installed flat families to the channelized topology. *base_pks* is the set of ch-0 interface pks the operator confirmed: ``None`` converts every - convertible family (the batch the background job runs), an empty collection converts none. A - family that cannot be converted is logged, appended to *conflicts* in the usual skipped-rename - shape and passed over — it is never half converted, and never costs the rest of the batch. - """ - if not supports_channelization(): - logger.warning( - "Rule '%s' converts flat families into the channelized topology, which this NetBox release " - "cannot model; nothing was converted.", - rule, - ) - return 0 - return _convert_flat_families(rule, base_pks, conflicts) # pragma: no cover - see above - - -def _convert_flat_families(rule, base_pks, conflicts): # pragma: no cover - requires channelization support - """Convert the confirmed flat families of *rule*; see ``convert_flat_families``.""" - if not _conversion_offered(rule): - return 0 - selected = None if base_pks is None else frozenset(base_pks) - if selected is not None and not selected: - return 0 + convertible family (the batch the background job runs), an empty collection converts none. - converted = 0 - for family in _conversion_families(rule): - if selected is not None and family.base.pk not in selected: - continue - current_name = family.base.name - reason = _convert_family(rule, family, commit=True) - if reason: - logger.warning( - "Cannot convert the flat family of interface %r on %s into the channelized parent %r: %s. Skipping.", - current_name, - family.module, - family.parent_name, - reason, - ) - _record_skip(conflicts, family.module.device, current_name, family.parent_name, family.base.pk) - continue - converted += 1 - return converted - - -def evaluate_name_template(template: str, variables: dict) -> str: - """Evaluate a name template with variable substitution and safe arithmetic. - - Supports templates like: - "GigabitEthernet{slot}/{8 + ({parent_bay_position} - 1) * 2 + {sfp_slot}}" - - Variables are substituted first, then any brace-enclosed expression - containing arithmetic operators is safely evaluated via AST. True division - (/) is not allowed — use floor division (//) instead. Results are cast to - int to ensure interface names are always whole numbers. + Returns the batch outcome: one explicit family result per family it planned. """ - # First pass: substitute all simple variables - result = template - for key, value in variables.items(): - result = result.replace(f"{{{key}}}", str(value)) - - # Second pass: evaluate any remaining brace-enclosed arithmetic expressions - def _eval_expr(match): - expr = match.group(1).strip() - # Allow digits, arithmetic operators (excluding lone /), parens, whitespace. - # Negative lookahead disallows a single / that is not part of //. - if not re.match(r"^(?!.*(?", "eval")))) # noqa: S307 - except (SyntaxError, TypeError, ZeroDivisionError) as e: - raise ValueError(f"Invalid arithmetic expression '{expr}': {e}") from e - - return re.sub(r"\{([^}]+)\}", _eval_expr, result) + selected = None if base_pks is None else frozenset(base_pks) + return family_ops.convert_rule_families(rule, _batch_modules(rule), selected_pks=selected) diff --git a/netbox_interface_name_rules/family/__init__.py b/netbox_interface_name_rules/family/__init__.py new file mode 100644 index 0000000..50aa43a --- /dev/null +++ b/netbox_interface_name_rules/family/__init__.py @@ -0,0 +1,131 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2025 Marcin Zieba +"""Plan and execute interface-family operations.""" + +from .batch import ( + BatchOutcome, + ModuleFamilyPlans, + apply_rule_to_modules, + execute_family_plan, + execute_module_families, + plan_module_families, +) +from .capabilities import supports_channelization +from .conversion import ( + conversion_offered, + convert_rule_families, + execute_conversion, + plan_module_conversions, + preview_rule_conversions, +) +from .domain import ( + ConversionCandidate, + ConversionMember, + ConversionPlan, + ConversionPreview, + FamilyOutcome, + FamilyStatus, + FamilyTopology, + FlatCreationPlan, + InstalledFamilyPlan, + InstalledFamilyPlanSet, + InstalledPlanSetOutcome, + InterfaceSnapshot, + MemberOutcome, + MemberRole, + PlannedChannel, + PlannedMember, + PlannedName, + ProspectiveFamilyPlan, + ProspectiveFamilyPlanSet, + ProspectiveMember, + StructuralFamilyPlan, +) +from .execution import execute_installed_plan, execute_installed_plan_set +from .installed import ( + device_interface_families, + interfaces_by_module, + is_channelized_parent, + module_db_alias, + plan_device_interface_rename, + plan_installed_families, + plan_interface_rename, +) +from .prospective import ( + ProspectiveInterface, + describe_interfaces, + describe_module_interfaces, + describe_template_interfaces, + plan_prospective_families, +) +from .structural import ( + execute_flat_family, + execute_structural_family, + has_flat_expansion, + install_channelized_family, + plan_flat_family, + plan_structural_family, +) +from .targets import channelized_family_names, one_family_per_name_set, template_channel_suffixes +from .template_names import pinned_template_cache, resolved_template_names + +__all__ = ( + "BatchOutcome", + "ConversionCandidate", + "ConversionMember", + "ConversionPlan", + "ConversionPreview", + "FamilyOutcome", + "FamilyStatus", + "FamilyTopology", + "FlatCreationPlan", + "InstalledFamilyPlan", + "InstalledFamilyPlanSet", + "InstalledPlanSetOutcome", + "InterfaceSnapshot", + "MemberOutcome", + "MemberRole", + "ModuleFamilyPlans", + "PlannedChannel", + "PlannedMember", + "PlannedName", + "ProspectiveFamilyPlan", + "ProspectiveFamilyPlanSet", + "ProspectiveInterface", + "ProspectiveMember", + "StructuralFamilyPlan", + "apply_rule_to_modules", + "channelized_family_names", + "conversion_offered", + "convert_rule_families", + "describe_interfaces", + "describe_module_interfaces", + "describe_template_interfaces", + "device_interface_families", + "execute_conversion", + "execute_family_plan", + "execute_flat_family", + "execute_installed_plan", + "execute_installed_plan_set", + "execute_module_families", + "execute_structural_family", + "has_flat_expansion", + "install_channelized_family", + "interfaces_by_module", + "is_channelized_parent", + "module_db_alias", + "one_family_per_name_set", + "pinned_template_cache", + "plan_device_interface_rename", + "plan_flat_family", + "plan_installed_families", + "plan_interface_rename", + "plan_module_conversions", + "plan_module_families", + "plan_prospective_families", + "plan_structural_family", + "preview_rule_conversions", + "resolved_template_names", + "supports_channelization", + "template_channel_suffixes", +) diff --git a/netbox_interface_name_rules/family/batch.py b/netbox_interface_name_rules/family/batch.py new file mode 100644 index 0000000..c3517eb --- /dev/null +++ b/netbox_interface_name_rules/family/batch.py @@ -0,0 +1,239 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2025 Marcin Zieba +"""Apply one rule to a batch of installed modules through family plans. + +Every module in the batch is planned family by family and executed family by family, so a module +that cannot take its names costs the batch only that family. The module rows, their interfaces and +each module type's templates are read once for the whole batch. +""" + +import logging +from dataclasses import dataclass + +from django.core.exceptions import ValidationError + +from ..naming import build_variables +from .domain import ( + FamilyOutcome, + FamilyStatus, + FamilyTopology, + FlatCreationPlan, + InstalledFamilyPlan, + MemberOutcome, + StructuralFamilyPlan, +) +from .execution import execute_installed_plan +from .installed import interfaces_by_module, plan_installed_families, plan_interface_rename +from .structural import execute_flat_family, execute_structural_family, plan_flat_family, plan_structural_family +from .targets import builds_channelized_family, intended_family_names, one_family_per_name_set +from .template_names import pinned_template_cache + +logger = logging.getLogger(__name__) + +# A member left with the name it had for a reason the operator can act on. An unsupported topology +# is not one of them: the release cannot hold the family, so nothing was dropped by this batch. +_SKIPPED_STATUSES = (FamilyStatus.BLOCKED, FamilyStatus.STALE, FamilyStatus.FAILED) + +_EXECUTORS = { + InstalledFamilyPlan: execute_installed_plan, + StructuralFamilyPlan: execute_structural_family, + FlatCreationPlan: execute_flat_family, +} + + +@dataclass(frozen=True, slots=True) +class ModuleFamilyPlans: + """The families a rule intends on one module, split by how each was found. + + *installed* are the families the module already carries; *leftover* are the plans for the + interfaces no installed family claimed, whether the rule renames one or builds a family on it. + """ + + installed: tuple + leftover: tuple + + @property + def plans(self) -> tuple: + """Return every plan, installed families first.""" + return (*self.installed, *self.leftover) + + +@dataclass(frozen=True, slots=True) +class BatchOutcome: + """Every family one batch operation planned, and what happened to it.""" + + families: tuple[FamilyOutcome, ...] + + @property + def changed_count(self) -> int: + """Return the number of interfaces the batch renamed or created.""" + return sum(family.changed_count for family in self.families) + + @property + def skipped_members(self) -> tuple[MemberOutcome, ...]: + """Return every member a collision, a stale plan or a failure left as it was.""" + return tuple( + member for family in self.families for member in family.members if member.status in _SKIPPED_STATUSES + ) + + @property + def changed_families(self) -> tuple[FamilyOutcome, ...]: + """Return every family this batch actually rewrote.""" + return tuple(family for family in self.families if family.status == FamilyStatus.CHANGED) + + @property + def blocked_families(self) -> tuple[FamilyOutcome, ...]: + """Return every family a collision, a stale plan or a failure left as it was.""" + return tuple(family for family in self.families if family.status in _SKIPPED_STATUSES) + + +def execute_family_plan(plan) -> FamilyOutcome: + """Execute one planned family through the executor its plan kind owns. + + A plan carrying no live rows (a prospective plan above all) has no executor, so a preview + object is refused here rather than locking anything. + """ + executor = _EXECUTORS.get(type(plan)) + if executor is None: + raise TypeError(f"{type(plan).__name__} is not an executable family plan") + return executor(plan) + + +def _is_channel(interface) -> bool: + """Return whether *interface* is bound to a parent channel.""" + return getattr(interface, "channel_id", None) is not None + + +def _creation_plan(module, rule, variables, base): + """Return the plan that builds the family *rule* describes on one plain interface.""" + if builds_channelized_family(rule): + return plan_structural_family(module, rule, variables, base) + return plan_flat_family(module, rule, variables, base) + + +def _creation_plans(module, rule, variables, plain): + """Return one creation plan per family, so two bases of one family never build it twice.""" + candidates = [(base, intended_family_names(rule, variables, base.name)) for base in plain] + kept = one_family_per_name_set([(base.name, target_names) for base, target_names in candidates]) + return [_creation_plan(module, rule, variables, candidates[index][0]) for index in kept] + + +def plan_module_families(module, rule, variables, interfaces, admit_leftover=None) -> ModuleFamilyPlans: + """Return one executable plan for every family *rule* intends on *module*. + + Every interface belongs to at most one plan: an installed family claims its members first, and + what is left over is planned as the family the rule would build on it. + + *admit_leftover* filters the interfaces no installed family claimed. It runs before two of + them that intend one family are collapsed into it, so a caller that must not touch one of the + two cannot have it survive the collapse as the row the family is built on. + """ + installed = plan_installed_families(module, rule, variables, interfaces=interfaces) + claimed = installed.member_pks + plain = [interface for interface in interfaces if interface.pk not in claimed and not _is_channel(interface)] + if admit_leftover is not None: + plain = list(admit_leftover(plain)) + if rule.channel_count <= 0: + leftover = tuple(plan_interface_rename(module, rule, variables, interface) for interface in plain) + elif any(plan.topology == FamilyTopology.CHANNELIZED for plan in installed.plans): + # A breakout rule renames the families the module already models; it never adds one beside them. + leftover = () # pragma: no cover - requires channelization support + for interface in plain: # pragma: no cover - see above + logger.debug( + "Interface %r is not channelized; skipping it while rule '%s' breaks out this module's families.", + interface.name, + rule, + ) + else: + leftover = tuple(_creation_plans(module, rule, variables, plain)) + return ModuleFamilyPlans(installed=installed.plans, leftover=leftover) + + +def _selection_pks(plan): + """Return the interface primary keys a selection reaches this family through. + + A channelized family is submitted through its parent alone, because a channel is not an + independent candidate on any path. + """ + if isinstance(plan, InstalledFamilyPlan): + if plan.parent_pk is None: + return plan.member_pks + return (plan.parent_pk,) # pragma: no cover - requires channelization support + return (plan.base.pk,) + + +def _selected(plans, selected_pks): + """Return the plans the operator's interface selection reaches.""" + if selected_pks is None: + return plans + return [plan for plan in plans if selected_pks.intersection(_selection_pks(plan))] + + +def execute_module_families(rule, module, plans): + """Execute each planned family in order, keeping the ones after a failure.""" + return [_execute(rule, module, plan) for plan in plans] + + +def _failed_members(plan, reason): + """Return failed member facts for the live rows an executor left unchanged.""" + return tuple( + MemberOutcome( + interface_pk=member.snapshot.pk, + current_name=member.snapshot.name, + target_name=member.target_name, + status=FamilyStatus.FAILED, + reason=reason, + ) + for member in plan.live_members + ) + + +def _failed_outcome(plan, error): + """Represent an executor exception without dropping the planned family from the batch.""" + reason = f"family execution failed: {error}" + return FamilyOutcome( + family_id=plan.family_id, + topology=plan.topology, + status=FamilyStatus.FAILED, + members=_failed_members(plan, reason), + reason=reason, + ) + + +def _execute(rule, module, plan): + """Execute one family, logging (never raising) so the batch keeps its later families.""" + try: + return execute_family_plan(plan) + except (ValueError, ValidationError) as error: + logger.exception( + "Failed to apply rule '%s' to a family on module '%s' (id=%s); skipping.", rule, module, module.pk + ) + return _failed_outcome(plan, error) + + +def _apply_module(rule, module, interfaces, selected_pks): + """Plan and execute every selected family on one module.""" + variables = build_variables(module.module_bay, device=module.device) + plans = _selected(plan_module_families(module, rule, variables, interfaces).plans, selected_pks) + return execute_module_families(rule, module, plans) + + +def apply_rule_to_modules(rule, modules, selected_pks=None, limit=None) -> BatchOutcome: + """Apply *rule* to every module in *modules*, one family at a time. + + *selected_pks* limits the batch to the families those interfaces reach; *limit* stops it after + the module that reached that many changed interfaces. + """ + if not modules: + return BatchOutcome(families=()) + by_module = interfaces_by_module(modules) + families: list[FamilyOutcome] = [] + changed = 0 + with pinned_template_cache(modules): + for module in modules: + outcomes = _apply_module(rule, module, by_module[module.pk], selected_pks) + families.extend(outcomes) + changed += sum(outcome.changed_count for outcome in outcomes) + if limit is not None and changed >= limit: + break + return BatchOutcome(families=tuple(families)) diff --git a/netbox_interface_name_rules/family/capabilities.py b/netbox_interface_name_rules/family/capabilities.py new file mode 100644 index 0000000..c4738b6 --- /dev/null +++ b/netbox_interface_name_rules/family/capabilities.py @@ -0,0 +1,19 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2025 Marcin Zieba +"""Probe what the active NetBox data model can represent.""" + +from dcim.models import Interface +from django.core.exceptions import FieldDoesNotExist + + +def supports_channelization() -> bool: + """Return whether this NetBox models channelized subinterfaces (4.7+). + + Probed from the Interface model rather than a version comparison, so a backport or a + development build is detected by what it actually provides. + """ + try: + Interface._meta.get_field("channel_id") + except FieldDoesNotExist: + return False + return True # pragma: no cover - only reachable on a NetBox that models channelization diff --git a/netbox_interface_name_rules/family/conversion.py b/netbox_interface_name_rules/family/conversion.py new file mode 100644 index 0000000..b05f9a8 --- /dev/null +++ b/netbox_interface_name_rules/family/conversion.py @@ -0,0 +1,446 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2025 Marcin Zieba +"""Convert installed flat breakout families into the channelized topology. + +An earlier flat apply leaves N sibling interfaces where NetBox 4.7+ models a channelized parent +with N channel subinterfaces. Converting one rewrites rows an operator owns (cables, addresses, +tags), so it is never a side effect of applying a rule: the operator confirms it per family. + +A family is identified by its ch-0 row, the way the flat apply that installed it named that row. +A family whose siblings the module no longer carries whole is still offered, and refused with the +row it is missing: silence would read as "nothing here to convert". +""" + +import logging +from dataclasses import replace + +from dcim.choices import InterfaceTypeChoices +from dcim.models import Interface +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction + +from ..naming import build_variables +from .batch import BatchOutcome +from .capabilities import supports_channelization +from .domain import ( + ConversionCandidate, + ConversionMember, + ConversionPlan, + ConversionPreview, + FamilyOutcome, + FamilyStatus, + FamilyTopology, + InterfaceSnapshot, + MemberOutcome, +) +from .installed import ( + TemplateNames, + family_names_for, + flat_family_bases, + interfaces_by_module, + is_plain_interface, + module_db_alias, +) +from .names import COLLISION_REASON, is_name_collision, name_is_taken +from .targets import builds_channelized_family, channelized_family_names +from .template_names import pinned_template_cache + +logger = logging.getLogger(__name__) + +STALE_REASON = "the flat family changed after it was scanned" +INCOMPLETE_REASON = "this module carries no complete flat family" +UNSUPPORTED_REASON = "this NetBox release cannot model channelized interfaces" + + +def conversion_offered(rule) -> bool: + """Return whether *rule* describes a topology an installed flat family could be converted into. + + A disabled rule renames nothing on any apply path, so it converts nothing either. A flat + family has no parent row (its ch-0 interface *is* the base), so without a parent name there is + nowhere for that base to go and the conversion is not offered at all. + """ + return rule.enabled and builds_channelized_family(rule) and bool(rule.parent_name_template) + + +# --------------------------------------------------------------------------- +# Planning +# --------------------------------------------------------------------------- + + +def _conversion_plan( + module, db_alias, parent_name, channel_names, rows, channelization_supported +): # pragma: no cover - channelization only + """Build one immutable conversion plan from the rows a module carries for one family.""" + base, *siblings = rows + plan = ConversionPlan( + family_id=f"conversion:{base.pk}", + device_id=module.device_id, + module_id=module.pk, + db_alias=db_alias, + base=InterfaceSnapshot.from_interface(base), + parent_target_name=parent_name, + channel_names=channel_names, + siblings=tuple( + ConversionMember(snapshot=InterfaceSnapshot.from_interface(sibling), channel_id=channel_id) + for channel_id, sibling in siblings + ), + ) + if not channelization_supported: + return replace( + plan, + precondition_status=FamilyStatus.UNSUPPORTED, + precondition_reason=UNSUPPORTED_REASON, + ) + missing = plan.missing_names + if not missing: + return plan + reason = f"{missing[0]!r} is missing: {INCOMPLETE_REASON}" + return replace(plan, precondition_status=FamilyStatus.BLOCKED, precondition_reason=reason) + + +def _family_rows(by_name, channel_names): # pragma: no cover - requires channelization support + """Return the ch-0 row and every sibling row this module still carries, with its channel id. + + A sibling is taken whatever it has become, so a row that now belongs to another parent is + reported against this family instead of quietly leaving a gap where it used to be. + """ + base = by_name.get(channel_names[0]) + if base is None or not is_plain_interface(base): + return None + siblings = [ + (channel_id, by_name[name]) for channel_id, name in enumerate(channel_names[1:], start=2) if name in by_name + ] + return [base, *siblings] + + +def plan_module_conversions( + module, rule, variables, interfaces +) -> tuple[ConversionPlan, ...]: # pragma: no cover - requires channelization support + """Return one plan for every flat family *rule* names on *module*, complete or not. + + The parent takes the name the rule resolves for this module now, so a family named before a + virtual-chassis renumber converts to the parent an apply would give it; the channels keep the + names they already carry, because converting retypes those rows in place. + """ + db_alias = module_db_alias(module) + catalog = TemplateNames(module) + by_name = {interface.name: interface for interface in interfaces} + channelization_supported = supports_channelization() + plans = [] + claimed = set() + for base_name, source_base in flat_family_bases(rule, variables, interfaces, catalog): + names = family_names_for(rule, variables, base_name, source_base) + if names is None: + continue + _target_names, channel_names = names + rows = _family_rows(by_name, channel_names) + if rows is None or rows[0].pk in claimed: + continue + claimed.add(rows[0].pk) + parent_name, _channels = channelized_family_names(rule, base_name, variables) + plans.append( + _conversion_plan( + module, + db_alias, + parent_name, + channel_names, + rows, + channelization_supported, + ) + ) + return tuple(plans) + + +def _planned_families(rule, modules): # pragma: no cover - requires channelization support + """Yield every convertible family in the batch as ``(module, plan, base interface)``. + + The module rows, their interfaces and each module type's templates are read once for the whole + batch, so a second module of the same type costs the scan no extra query. + """ + by_module = interfaces_by_module(modules) + for module in modules: + interfaces = by_module[module.pk] + rows_by_pk = {interface.pk: interface for interface in interfaces} + variables = build_variables(module.module_bay, device=module.device) + for plan in plan_module_conversions(module, rule, variables, interfaces): + yield module, plan, rows_by_pk[plan.base.pk] + + +# --------------------------------------------------------------------------- +# Execution +# --------------------------------------------------------------------------- + + +def _outcome(plan, status, members, reason=""): # pragma: no cover - requires channelization support + """Build the immutable outcome of one conversion.""" + return FamilyOutcome( + family_id=plan.family_id, + topology=FamilyTopology.CHANNELIZED, + status=status, + members=members, + reason=reason, + ) + + +def _refused(plan, status, reason): # pragma: no cover - requires channelization support + """Log why the family was not converted and return an outcome that touched no row.""" + logger.warning( + "Cannot convert the flat family of interface %r (device %s, module %s) into the channelized " + "parent %r: %s. Skipping.", + plan.base.name, + plan.device_id, + plan.module_id, + plan.parent_target_name, + reason, + ) + targets = (plan.parent_target_name, *plan.sibling_target_names) + members = tuple( + MemberOutcome( + interface_pk=pk, + current_name=current_name, + target_name=target_name, + status=status, + reason=reason, + ) + for pk, current_name, target_name in zip(plan.member_pks, plan.current_names, targets, strict=True) + ) + return _outcome(plan, status, members, reason) + + +def _validate_or_block(interface, role): # pragma: no cover - requires channelization support + """Run NetBox's own validation on *interface*, restating a rejection as this family's reason.""" + try: + interface.full_clean() + except ValidationError as error: + raise ValidationError(f"{role} {interface.name!r}: {' '.join(error.messages)}") from error + + +def _locked_family(plan): # pragma: no cover - requires channelization support + """Lock every planned row for the transaction and return the live rows by primary key.""" + return { + interface.pk: interface + for interface in ( + Interface.objects.using(plan.db_alias) + .select_for_update(of=("self",)) # module is nullable, so locking the join is refused + .select_related("device", "module") + .filter(pk__in=plan.member_pks) + .order_by("pk") + ) + } + + +def _is_stale(plan, live) -> bool: # pragma: no cover - requires channelization support + """Return whether live identity, names, membership or topology changed since the scan.""" + planned = {plan.base.pk: plan.base, **{member.snapshot.pk: member.snapshot for member in plan.siblings}} + return planned != {pk: InterfaceSnapshot.from_interface(interface) for pk, interface in live.items()} + + +def _blocking_reason(plan, live) -> str: # pragma: no cover - requires channelization support + """Return why this family cannot become a channelized family, or an empty string. + + Only what upstream cannot decide for us is checked here; everything else is left to + ``full_clean()`` on each prospective row, which inherits upstream's rules as they grow. + """ + if name_is_taken(plan.device_id, plan.parent_target_name, plan.db_alias, exclude_pk=plan.base.pk): + return f"the parent name {plan.parent_target_name!r} is already taken on this device" + for member in plan.siblings: + sibling = live[member.snapshot.pk] + # Rebinding it validates cleanly, so only this check keeps the other family whole. + if sibling.channel_id is not None: + owner = sibling.parent.name if sibling.parent_id else "another parent" + return ( + f"{sibling.name!r} is already channel {sibling.channel_id} of {owner}; " + f"converting would take it out of that family" + ) + # TYPE_CHANNEL is nonconnectable but not virtual, so Interface.clean() accepts a cable on one. + if sibling.cable_id: + return f"{sibling.name!r} has a cable attached; a channel takes its cable from the parent" + return "" + + +def _carry_assignments(plan, base, channel): # pragma: no cover - requires channelization support + """Move the ch-0 row's addresses and first-hop groups onto the channel that took its name. + + Saved one row at a time so each carried object is validated, and recorded in the changelog, + like every other row this conversion writes. A model save writes back every field it read, so + each row is locked first: an edit landing between the read and the save would otherwise be + overwritten by the values this transaction started with. + """ + addresses = base.ip_addresses.using(plan.db_alias).select_for_update().order_by("pk") + for address in addresses: + address.assigned_object = channel + address.full_clean() + address.save(using=plan.db_alias) + assignments = base.fhrp_group_assignments.using(plan.db_alias).select_for_update().order_by("pk") + for assignment in assignments: + assignment.interface = channel + assignment.full_clean() + assignment.save(using=plan.db_alias) + + +def _split_base(plan, base): # pragma: no cover - requires channelization support + """Make *base* the family parent and move its logical identity onto a new channel-1 row. + + Everything an operator configured on the ch-0 row described a channel, not the cage carrying + it, so addresses, VLANs, MTU, description and tags move; custom fields can mean either thing + and are copied. The physical row keeps its pk, cable, type, module link and mark_connected. + """ + carried = { + "description": base.description, + "mtu": base.mtu, + "mode": base.mode, + "untagged_vlan_id": base.untagged_vlan_id, + "vrf_id": base.vrf_id, + } + tagged_vlans = list(base.tagged_vlans.all()) + tags = list(base.tags.all()) + channel_name = base.name + + base.name = plan.parent_target_name + base.channels = plan.channel_count + base.description = "" + base.mtu = None + base.mode = "" + base.untagged_vlan = None + base.vrf = None + _validate_or_block(base, "parent") + base.save(using=plan.db_alias) # BaseInterface.save() drops the tagged VLANs of a row that no longer tags + base.tags.clear() + + channel = Interface( + device=base.device, + module=base.module, + name=channel_name, + type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=base, + channel_id=1, + enabled=base.enabled, + custom_field_data=dict(base.custom_field_data or {}), + **carried, + ) + _validate_or_block(channel, "channel") + channel.save(using=plan.db_alias) + channel.tagged_vlans.set(tagged_vlans) + channel.tags.set(tags) + _carry_assignments(plan, base, channel) + return channel + + +def _rewrite(plan, live): # pragma: no cover - requires channelization support + """Rewrite the whole family and return one outcome per row it wrote.""" + base = live[plan.base.pk] + previous_name = base.name + channel = _split_base(plan, base) + members = [ + MemberOutcome(base.pk, previous_name, plan.parent_target_name, FamilyStatus.CHANGED), + MemberOutcome(channel.pk, channel.name, channel.name, FamilyStatus.CHANGED), + ] + for member in plan.siblings: + sibling = live[member.snapshot.pk] + sibling.type = InterfaceTypeChoices.TYPE_CHANNEL + sibling.parent = base + sibling.channel_id = member.channel_id + _validate_or_block(sibling, "channel") + sibling.save(using=plan.db_alias) + members.append(MemberOutcome(sibling.pk, sibling.name, sibling.name, FamilyStatus.CHANGED)) + return tuple(members) + + +def _convert(plan, commit): # pragma: no cover - requires channelization support + """Convert one family inside a single transaction, or leave every row exactly as it was. + + A dry run (*commit* False) and a family that turns out to be unconvertible both roll the whole + rewrite back, so a family is never half converted and a scan writes nothing at all. + """ + try: + with transaction.atomic(using=plan.db_alias): + live = _locked_family(plan) + if _is_stale(plan, live): + return _refused(plan, FamilyStatus.STALE, STALE_REASON) + reason = _blocking_reason(plan, live) + if reason: + return _refused(plan, FamilyStatus.BLOCKED, reason) + members = _rewrite(plan, live) + if not commit: + transaction.set_rollback(True, using=plan.db_alias) + except ValidationError as error: + return _refused(plan, FamilyStatus.BLOCKED, " ".join(error.messages)) + except IntegrityError as error: + if not is_name_collision(error): + raise + return _refused(plan, FamilyStatus.BLOCKED, COLLISION_REASON) + return _outcome(plan, FamilyStatus.CHANGED, members) + + +def _dry_run(plan) -> FamilyOutcome: # pragma: no cover - requires channelization support + """Report what converting *plan* would do, having written and rolled back every row of it.""" + if plan.precondition_status is not None: + return _refused(plan, plan.precondition_status, plan.precondition_reason) + return _convert(plan, commit=False) + + +def execute_conversion(plan: ConversionPlan) -> FamilyOutcome: # pragma: no cover - requires channelization support + """Convert the planned flat family, or leave every row exactly as it was. + + Only a conversion plan names the family rows to rewrite, so anything else (a prospective plan + above all) is refused before a single row is locked. + """ + if not isinstance(plan, ConversionPlan): + raise TypeError(f"{type(plan).__name__} is not an executable conversion plan") + if plan.precondition_status is not None: + return _refused(plan, plan.precondition_status, plan.precondition_reason) + return _convert(plan, commit=True) + + +# --------------------------------------------------------------------------- +# Batch entry points +# --------------------------------------------------------------------------- + + +def preview_rule_conversions(rule, modules, limit=None) -> ConversionPreview: + """Return what converting each of *rule*'s flat families would do, convertible or not. + + Nothing is written: every family is converted inside a savepoint that is rolled back again, so + each candidate carries the reason NetBox itself would refuse the family rather than a guess at + its rules. That dry run is what the scan costs, and a blocked family costs it too, so *limit* + caps the families examined rather than the convertible ones among them. + """ + if not conversion_offered(rule): + return ConversionPreview(candidates=()) + return _preview(rule, modules, limit) # pragma: no cover - requires channelization support + + +def _preview(rule, modules, limit) -> ConversionPreview: # pragma: no cover - requires channelization support + """Dry-run at most *limit* of the rule's flat families; see ``preview_rule_conversions``.""" + candidates = [] + with pinned_template_cache(modules): + for module, plan, base in _planned_families(rule, modules): + if limit is not None and len(candidates) >= limit: + return ConversionPreview(candidates=tuple(candidates), has_more=True) + outcome = _dry_run(plan) + candidates.append(ConversionCandidate(plan=plan, module=module, interface=base, reason=outcome.reason)) + return ConversionPreview(candidates=tuple(candidates)) + + +def convert_rule_families(rule, modules, selected_pks=None) -> BatchOutcome: + """Convert the confirmed flat families of *rule*; return one explicit outcome per family. + + *selected_pks* is the set of ch-0 interface primary keys the operator confirmed: ``None`` + converts every convertible family (the batch the background job runs), an empty collection + converts none. A family that cannot be converted is reported and passed over, so it is never + half converted and never costs the rest of the batch. + """ + return _convert_families(rule, modules, selected_pks) # pragma: no cover - see above + + +def _convert_families(rule, modules, selected_pks) -> BatchOutcome: # pragma: no cover - channelization only + """Convert the confirmed flat families of *rule*; see ``convert_rule_families``.""" + if not conversion_offered(rule) or (selected_pks is not None and not selected_pks): + return BatchOutcome(families=()) + families = [] + with pinned_template_cache(modules): + for _module, plan, _base in _planned_families(rule, modules): + if selected_pks is not None and plan.base.pk not in selected_pks: + continue + families.append(execute_conversion(plan)) + return BatchOutcome(families=tuple(families)) diff --git a/netbox_interface_name_rules/family/domain.py b/netbox_interface_name_rules/family/domain.py new file mode 100644 index 0000000..4d92061 --- /dev/null +++ b/netbox_interface_name_rules/family/domain.py @@ -0,0 +1,412 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2025 Marcin Zieba +"""Immutable domain values for interface-family operations.""" + +from dataclasses import dataclass +from enum import StrEnum + + +class FamilyTopology(StrEnum): + """Installed interface-family topology.""" + + FLAT = "flat" + CHANNELIZED = "channelized" + + +class FamilyStatus(StrEnum): + """Result of a family or member operation.""" + + CHANGED = "changed" + UNCHANGED = "unchanged" + BLOCKED = "blocked" + STALE = "stale" + UNSUPPORTED = "unsupported" + FAILED = "failed" + + +class MemberRole(StrEnum): + """A member's role in its installed family.""" + + PARENT = "parent" + CHANNEL = "channel" + FLAT_MEMBER = "flat_member" + + +@dataclass(frozen=True, slots=True) +class InterfaceSnapshot: + """Live interface facts that execution must revalidate.""" + + pk: int + device_id: int + module_id: int | None + name: str + parent_id: int | None + channel_id: int | None + channels: int | None + + @classmethod + def from_interface(cls, interface): + """Capture the protected facts from a NetBox interface row.""" + return cls( + pk=interface.pk, + device_id=interface.device_id, + module_id=interface.module_id, + name=interface.name, + parent_id=getattr(interface, "parent_id", None), + channel_id=getattr(interface, "channel_id", None), + channels=getattr(interface, "channels", None), + ) + + +@dataclass(frozen=True, slots=True) +class PlannedMember: + """One installed member and its intended name.""" + + snapshot: InterfaceSnapshot + target_name: str | None + role: MemberRole + reason: str = "" + + +@dataclass(frozen=True, slots=True) +class InstalledFamilyPlan: + """An executable rename plan for one installed interface family.""" + + family_id: str + topology: FamilyTopology + device_id: int + module_id: int | None + db_alias: str + members: tuple[PlannedMember, ...] + parent_pk: int | None = None + precondition_status: FamilyStatus | None = None + precondition_reason: str = "" + + @property + def member_pks(self) -> tuple[int, ...]: + """Return member primary keys in plan order.""" + return tuple(member.snapshot.pk for member in self.members) + + @property + def live_members(self) -> tuple[PlannedMember, ...]: + """Return the planned members that already have live interface rows.""" + return self.members + + +@dataclass(frozen=True, slots=True) +class InstalledFamilyPlanSet: + """Exactly one immutable plan for each discovered installed family.""" + + module_id: int + plans: tuple[InstalledFamilyPlan, ...] + + @property + def member_pks(self) -> frozenset[int]: + """Return every interface claimed by the plan set.""" + return frozenset(pk for plan in self.plans for pk in plan.member_pks) + + +@dataclass(frozen=True, slots=True) +class PlannedChannel: + """One channel row a structural plan creates under its parent.""" + + channel_id: int + name: str + + +@dataclass(frozen=True, slots=True) +class StructuralFamilyPlan: + """An executable plan that turns one plain interface into a channelized family.""" + + family_id: str + device_id: int + module_id: int + module_type_id: int + db_alias: str + base: InterfaceSnapshot + parent_target_name: str + channel_count: int + channels: tuple[PlannedChannel, ...] + precondition_status: FamilyStatus | None = None + precondition_reason: str = "" + + @property + def topology(self) -> FamilyTopology: + """Return the topology this plan installs.""" + return FamilyTopology.CHANNELIZED + + @property + def target_names(self) -> tuple[str, ...]: + """Return the parent name and every channel name in creation order.""" + return (self.parent_target_name, *(channel.name for channel in self.channels)) + + @property + def live_members(self) -> tuple[PlannedMember, ...]: + """Return the base row this plan rewrites.""" + return (PlannedMember(self.base, self.parent_target_name, MemberRole.PARENT),) + + +@dataclass(frozen=True, slots=True) +class FlatCreationPlan: + """An executable plan that expands one plain interface into a flat breakout family.""" + + family_id: str + device_id: int + module_id: int + db_alias: str + base: InterfaceSnapshot + target_names: tuple[str, ...] + precondition_status: FamilyStatus | None = None + precondition_reason: str = "" + + @property + def topology(self) -> FamilyTopology: + """Return the topology this plan installs.""" + return FamilyTopology.FLAT + + @property + def live_members(self) -> tuple[PlannedMember, ...]: + """Return the base row this plan rewrites.""" + return (PlannedMember(self.base, self.target_names[0], MemberRole.FLAT_MEMBER),) + + +@dataclass(frozen=True, slots=True) +class ProspectiveMember: + """One member of a family planned from names alone.""" + + source_name: str | None + target_name: str + role: MemberRole + channel_id: int | None = None + reason: str = "" + + +@dataclass(frozen=True, slots=True) +class ProspectiveFamilyPlan: + """What a rule intends for one family of named interfaces. + + A plan whose *base_name* is set describes a family the rule builds out of that one name, so the + name expands to every target. A plan without one renames members that already exist. + """ + + family_id: str + topology: FamilyTopology + base_name: str | None + members: tuple[ProspectiveMember, ...] + precondition_status: FamilyStatus | None = None + precondition_reason: str = "" + + @property + def source_names(self) -> tuple[str, ...]: + """Return the name of every member that already exists, in plan order.""" + return tuple(member.source_name for member in self.members if member.source_name is not None) + + @property + def target_names(self) -> tuple[str, ...]: + """Return every intended name, in plan order.""" + return tuple(member.target_name for member in self.members) + + +@dataclass(frozen=True, slots=True) +class ProspectiveFamilyPlanSet: + """Exactly one prospective plan for each family the rule intends on a module.""" + + module_id: int + plans: tuple[ProspectiveFamilyPlan, ...] + + def predicted_names(self, source_name: str) -> tuple[str, ...]: + """Return the names *source_name* becomes, or the name itself when no plan claims it.""" + for plan in self.plans: + if plan.base_name == source_name: + return plan.target_names + for member in plan.members: + if member.source_name == source_name: + return (member.target_name,) + return (source_name,) + + +@dataclass(frozen=True, slots=True) +class MemberOutcome: + """Result facts for one planned family member.""" + + interface_pk: int + current_name: str + target_name: str | None + status: FamilyStatus + reason: str = "" + + +@dataclass(frozen=True, slots=True) +class FamilyOutcome: + """Execution result for one installed family.""" + + family_id: str + topology: FamilyTopology + status: FamilyStatus + members: tuple[MemberOutcome, ...] + reason: str = "" + + @property + def changed_count(self) -> int: + """Return the number of members renamed by this operation.""" + return sum(member.status == FamilyStatus.CHANGED for member in self.members) + + +@dataclass(frozen=True, slots=True) +class InstalledPlanSetOutcome: + """Execution results for an installed family plan set.""" + + families: tuple[FamilyOutcome, ...] + + @property + def changed_count(self) -> int: + """Return the number of members renamed across all families.""" + return sum(family.changed_count for family in self.families) + + +@dataclass(frozen=True, slots=True) +class ConversionMember: # pragma: no cover - requires channelization support + """One installed flat sibling and the channel identifier it takes.""" + + snapshot: InterfaceSnapshot + channel_id: int + + +@dataclass(frozen=True, slots=True) +class ConversionPlan: # pragma: no cover - requires channelization support + """An executable plan that turns one installed flat family into a channelized family. + + The ch-0 row keeps its primary key and becomes the parent, giving up its name to a new + channel-1 row; each sibling is retyped in place as a channel under the name it already has. + + *channel_names* is every name the rule spells for the family, whether or not the module still + carries a row for it, so a family with a gap can still say what it would have become. + """ + + family_id: str + device_id: int + module_id: int + db_alias: str + base: InterfaceSnapshot + parent_target_name: str + channel_names: tuple[str, ...] + siblings: tuple[ConversionMember, ...] + precondition_status: FamilyStatus | None = None + precondition_reason: str = "" + + @property + def topology(self) -> FamilyTopology: + """Return the topology this plan installs.""" + return FamilyTopology.CHANNELIZED + + @property + def channel_count(self) -> int: + """Return how many channels the converted parent declares.""" + return len(self.channel_names) + + @property + def member_pks(self) -> tuple[int, ...]: + """Return the primary key of every row the plan rewrites, in family order.""" + return (self.base.pk, *(sibling.snapshot.pk for sibling in self.siblings)) + + @property + def current_names(self) -> tuple[str, ...]: + """Return the names the family carries now, in family order.""" + return (self.base.name, *(sibling.snapshot.name for sibling in self.siblings)) + + @property + def sibling_target_names(self) -> tuple[str, ...]: + """Return the name each installed sibling keeps as a channel, in family order.""" + return tuple(sibling.snapshot.name for sibling in self.siblings) + + @property + def missing_names(self) -> tuple[str, ...]: + """Return every name the family needs that this module carries no row for.""" + present = {self.base.name, *(sibling.snapshot.name for sibling in self.siblings)} + return tuple(name for name in self.channel_names if name not in present) + + @property + def target_names(self) -> tuple[str, ...]: + """Return the parent name and every channel name, in family order.""" + return (self.parent_target_name, *self.channel_names) + + +@dataclass(frozen=True, slots=True) +class PlannedName: # pragma: no cover - requires channelization support + """One name a family plan intends, and the role it fills in that family.""" + + name: str + role: str + channel_id: int | None = None + + +@dataclass(frozen=True, slots=True) +class ConversionCandidate: # pragma: no cover - requires channelization support + """One flat family an operator may convert, and what converting it would do. + + *module* and *interface* are the live rows the Apply page links to and submits; every name, + role and reason on the candidate comes from the immutable plan instead. + """ + + plan: ConversionPlan + module: object + interface: object + reason: str = "" + + @property + def convertible(self) -> bool: + """Return whether a dry run of this family succeeded.""" + return not self.reason + + @property + def status_label(self) -> str: + """Return the operator-facing result of the conversion preview.""" + if self.convertible: + return "Convertible" + if self.plan.precondition_status == FamilyStatus.UNSUPPORTED: + return "Unsupported" + return "Blocked" + + @property + def current_name(self) -> str: + """Return the name of the ch-0 row the confirm form submits.""" + return self.plan.base.name + + @property + def current_names(self) -> tuple[str, ...]: + """Return the names the family carries now.""" + return self.plan.current_names + + @property + def new_names(self) -> tuple[str, ...]: + """Return the names the family would carry once converted.""" + return self.plan.target_names + + @property + def name_details(self) -> tuple[PlannedName, ...]: + """Describe every intended name so the page can render a family as a family.""" + return ( + PlannedName(self.plan.parent_target_name, "parent"), + *( + PlannedName(name, "channel", channel_id) + for channel_id, name in enumerate(self.plan.channel_names, start=1) + ), + ) + + @property + def metadata_note(self) -> str: + """Return the sentence the Apply page shows about where the ch-0 configuration ends up.""" + return ( + f"The interface VRF, addresses, VLANs, MTU, description and tags on {self.plan.base.name} move to the new " + f"channel 1 interface that takes over that name; custom field values are copied. The physical " + f"row keeps its ID and becomes the parent {self.plan.parent_target_name}, so automation keyed " + f"on that interface ID will address the parent afterwards." + ) + + +@dataclass(frozen=True, slots=True) +class ConversionPreview: + """Every flat family a conversion scan examined, and whether it left any unexamined.""" + + candidates: tuple[ConversionCandidate, ...] + has_more: bool = False diff --git a/netbox_interface_name_rules/family/execution.py b/netbox_interface_name_rules/family/execution.py new file mode 100644 index 0000000..28e2607 --- /dev/null +++ b/netbox_interface_name_rules/family/execution.py @@ -0,0 +1,223 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2025 Marcin Zieba +"""Execute installed interface-family plans.""" + +import logging + +from dcim.models import Interface +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction +from django.db.models import Q + +from .domain import ( + FamilyOutcome, + FamilyStatus, + InstalledFamilyPlan, + InstalledFamilyPlanSet, + InstalledPlanSetOutcome, + InterfaceSnapshot, + MemberOutcome, + MemberRole, +) +from .names import COLLISION_REASON, is_name_collision, name_is_taken, reconcile_after_parent_cascade + +logger = logging.getLogger(__name__) + +STALE_REASON = "installed family changed after planning" +PARENT_BLOCKED_REASON = "family parent was blocked" + + +def _lock_family(plan: InstalledFamilyPlan): + """Lock and return the current family rows in stable primary-key order.""" + queryset = Interface.objects.using(plan.db_alias) + if plan.parent_pk is None: + queryset = queryset.filter(pk__in=plan.member_pks) + else: # pragma: no cover - requires channelization support + queryset = queryset.filter(Q(pk=plan.parent_pk) | Q(parent_id=plan.parent_pk)) + return list(queryset.select_for_update().order_by("pk")) + + +def _is_stale(plan: InstalledFamilyPlan, interfaces) -> bool: + """Return whether live identity, names, membership, or topology changed.""" + planned = {member.snapshot.pk: member.snapshot for member in plan.members} + live = {interface.pk: InterfaceSnapshot.from_interface(interface) for interface in interfaces} + return planned != live + + +def _member_outcome(member, status, reason=""): + """Build one immutable member outcome from its plan facts.""" + return MemberOutcome( + interface_pk=member.snapshot.pk, + current_name=member.snapshot.name, + target_name=member.target_name, + status=status, + reason=reason, + ) + + +def _blocked_member(member, reason): + """Return a blocked member outcome.""" + return _member_outcome(member, FamilyStatus.BLOCKED, reason) + + +def _rename_member(member, interface, db_alias): + """Apply one planned name inside a savepoint and return its explicit outcome.""" + target_name = member.target_name + if target_name is None: + logger.warning( + "Cannot derive a name for channel interface %r; leaving it unchanged.", + member.snapshot.name, + ) + return _blocked_member(member, member.reason) + if target_name == interface.name: + return _member_outcome(member, FamilyStatus.UNCHANGED) + if name_is_taken(interface.device_id, target_name, db_alias, exclude_pk=interface.pk): + logger.warning( + "Interface name %r already exists on device %s; skipping rename of %r to %r.", + target_name, + interface.device_id, + interface.name, + target_name, + ) + return _blocked_member(member, COLLISION_REASON) + + previous_name = interface.name + try: + with transaction.atomic(using=db_alias): + interface.name = target_name + interface.full_clean() + interface.save(using=db_alias) + except ValidationError as error: + interface.name = previous_name + logger.warning("NetBox rejected rename of %r to %r: %s", previous_name, target_name, error) + return _blocked_member(member, " ".join(error.messages)) + except IntegrityError as error: + interface.name = previous_name + if is_name_collision(error): + logger.warning( + "Interface name %r became occupied while renaming %r; skipping.", + target_name, + previous_name, + ) + return _blocked_member(member, COLLISION_REASON) + raise + return _member_outcome(member, FamilyStatus.CHANGED) + + +def _family_status(members): + """Summarize member outcomes without hiding partial success.""" + statuses = {member.status for member in members} + if FamilyStatus.CHANGED in statuses: + return FamilyStatus.CHANGED + if FamilyStatus.BLOCKED in statuses: + return FamilyStatus.BLOCKED + return FamilyStatus.UNCHANGED + + +def _preserve_names_across_parent_cascade(plan, member_outcomes): # pragma: no cover - channelization only + """Schedule restoration of blocked conventional channel names after a parent cascade.""" + if plan.parent_pk is None: + return + parent_member = next(member for member in plan.members if member.role == MemberRole.PARENT) + parent_outcome = next(outcome for outcome in member_outcomes if outcome.interface_pk == plan.parent_pk) + if parent_outcome.status != FamilyStatus.CHANGED: + return + outcomes_by_pk = {outcome.interface_pk: outcome for outcome in member_outcomes} + channels = tuple( + ( + member.snapshot.pk, + member.snapshot.channel_id, + member.target_name + if outcomes_by_pk[member.snapshot.pk].status in (FamilyStatus.CHANGED, FamilyStatus.UNCHANGED) + else member.snapshot.name, + ) + for member in plan.members + if member.role == MemberRole.CHANNEL + ) + reconcile_after_parent_cascade(parent_member.snapshot.name, parent_member.target_name, channels, plan.db_alias) + + +def _execute_channelized_members(plan, live_by_pk): # pragma: no cover - requires channelization support + """Execute a channelized family after its parent succeeds.""" + parent_member = next(member for member in plan.members if member.role == MemberRole.PARENT) + parent_outcome = _rename_member(parent_member, live_by_pk[parent_member.snapshot.pk], plan.db_alias) + if parent_outcome.status == FamilyStatus.BLOCKED: + return ( + parent_outcome, + *( + _blocked_member(member, PARENT_BLOCKED_REASON) + for member in plan.members + if member.role != MemberRole.PARENT + ), + ) + return ( + parent_outcome, + *( + _rename_member(member, live_by_pk[member.snapshot.pk], plan.db_alias) + for member in plan.members + if member.role != MemberRole.PARENT + ), + ) + + +def _execute_members(plan, live_by_pk): + """Execute members while enforcing parent-first family semantics.""" + if plan.parent_pk is None: + return tuple(_rename_member(member, live_by_pk[member.snapshot.pk], plan.db_alias) for member in plan.members) + return _execute_channelized_members(plan, live_by_pk) # pragma: no cover - channelization only + + +def _stale_outcome(plan: InstalledFamilyPlan) -> FamilyOutcome: + """Return a stale result without applying any planned member.""" + return FamilyOutcome( + family_id=plan.family_id, + topology=plan.topology, + status=FamilyStatus.STALE, + members=tuple(_member_outcome(member, FamilyStatus.STALE, STALE_REASON) for member in plan.members), + reason=STALE_REASON, + ) + + +def execute_installed_plan(plan: InstalledFamilyPlan) -> FamilyOutcome: + """Execute one family plan in its own transaction.""" + with transaction.atomic(using=plan.db_alias): + interfaces = _lock_family(plan) + if _is_stale(plan, interfaces): + return _stale_outcome(plan) + if plan.precondition_status is not None: + logger.warning( + "Installed family of interface %r is %s: %s.", + plan.members[0].snapshot.name, + plan.precondition_status, + plan.precondition_reason, + ) + return FamilyOutcome( + family_id=plan.family_id, + topology=plan.topology, + status=plan.precondition_status, + members=tuple( + _member_outcome(member, plan.precondition_status, plan.precondition_reason) + for member in plan.members + ), + reason=plan.precondition_reason, + ) + live_by_pk = {interface.pk: interface for interface in interfaces} + member_outcomes = _execute_members(plan, live_by_pk) + _preserve_names_across_parent_cascade(plan, member_outcomes) + return FamilyOutcome( + family_id=plan.family_id, + topology=plan.topology, + status=_family_status(member_outcomes), + members=member_outcomes, + ) + + +def execute_installed_plan_set(plan_set: InstalledFamilyPlanSet) -> InstalledPlanSetOutcome: + """Execute each installed family in a separate transaction. + + Only an installed plan set carries the row snapshots execution revalidates, so anything else + (a prospective plan set above all) is refused before a single row is locked. + """ + if not isinstance(plan_set, InstalledFamilyPlanSet): + raise TypeError(f"{type(plan_set).__name__} is not an executable plan set") + return InstalledPlanSetOutcome(families=tuple(execute_installed_plan(plan) for plan in plan_set.plans)) diff --git a/netbox_interface_name_rules/family/installed.py b/netbox_interface_name_rules/family/installed.py new file mode 100644 index 0000000..db365e6 --- /dev/null +++ b/netbox_interface_name_rules/family/installed.py @@ -0,0 +1,373 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2025 Marcin Zieba +"""Discover and plan installed interface families.""" + +import logging +import re + +from dcim.models import Interface +from django.db import DEFAULT_DB_ALIAS + +from ..choices import BreakoutModeChoices +from ..naming import evaluate_name_template +from .domain import ( + FamilyStatus, + FamilyTopology, + InstalledFamilyPlan, + InstalledFamilyPlanSet, + InterfaceSnapshot, + MemberRole, + PlannedMember, +) +from .targets import ( + channelized_family_targets, + flat_family_names, + lockstep_family_targets, + template_channel_suffixes, +) +from .template_names import resolved_template_names + +logger = logging.getLogger(__name__) + +_BASE_SENTINEL = "InrBaseSentinelEnd" + + +def _db_alias(instance) -> str: + """Return the database alias that *instance* was loaded from.""" + return instance._state.db or DEFAULT_DB_ALIAS + + +def module_db_alias(module) -> str: + """Return the database alias that *module* was loaded from.""" + return _db_alias(module) + + +def is_plain_interface(interface) -> bool: + """Return whether *interface* can be a flat-family member.""" + return ( + getattr(interface, "parent_id", None) is None + and getattr(interface, "channel_id", None) is None + and getattr(interface, "channels", None) is None + ) + + +def is_channelized_parent(interface) -> bool: # pragma: no cover - requires channelization support + """Return whether *interface* declares a channelized family.""" + return getattr(interface, "channels", None) is not None + + +def _is_channel(interface) -> bool: # pragma: no cover - requires channelization support + """Return whether *interface* is bound to a parent channel.""" + return getattr(interface, "channel_id", None) is not None + + +def _historical_bases(rule, variables, template, interfaces): # pragma: no cover - requires VC token support + """Return every historical base claimed by *template*.""" + if template.historical_pattern is None: + return () + try: + marked = evaluate_name_template( + rule.name_template, + {**variables, "base": _BASE_SENTINEL, "channel": str(rule.channel_start)}, + ) + except (TypeError, ValueError): + return () + if _BASE_SENTINEL not in marked: + return () + escaped = re.escape(marked) + head, _, tail = escaped.partition(_BASE_SENTINEL) + tail = tail.replace(_BASE_SENTINEL, "(?P=base)") + pattern = re.compile(f"{head}(?P{template.historical_pattern.pattern}){tail}") + bases = { + match.group("base") for interface in interfaces if (match := pattern.fullmatch(interface.name)) is not None + } + return tuple(sorted(bases)) + + +def _ambiguous_bases(historical_by_template): + """Return every historical base that more than one template could claim.""" + ambiguous = {base_name for bases in historical_by_template.values() if len(bases) > 1 for base_name in bases} + single_claims: dict[str, int] = {} + for bases in historical_by_template.values(): + if len(bases) == 1: # pragma: no cover - historical matchers require VC token support + single_claims[bases[0]] = single_claims.get(bases[0], 0) + 1 + ambiguous.update(base_name for base_name, count in single_claims.items() if count > 1) + return ambiguous + + +def _source_bases(template, historical_bases, ambiguous_bases): + """Return the template's own base and every historical base it alone claims.""" + unambiguous = tuple( + base_name for base_name in historical_bases if len(historical_bases) == 1 and base_name not in ambiguous_bases + ) + return (template.resolved, *unambiguous) + + +def flat_family_bases(rule, variables, interfaces, catalog): + """Return ``(template base, source base)`` for every base a flat family could be named from. + + The template base is the name the rule resolves for this module now; the source base is the one + an installed family still spells, which differs after a virtual-chassis renumber. A historical + base more than one template could claim is dropped: the rows it names are not certainly one + family's, and neither renaming nor converting them is this plugin's guess to make. + """ + if rule.channel_count <= 0: + return () + templates = catalog.get() + historical_by_template = { + template.pk: _historical_bases(rule, variables, template, interfaces) for template in templates + } + ambiguous_bases = _ambiguous_bases(historical_by_template) + return tuple( + (template.resolved, source_base) + for template in templates + for source_base in _source_bases(template, historical_by_template[template.pk], ambiguous_bases) + ) + + +def family_names_for(rule, variables, base_name, source_base): + """Return the names the rule intends for the family and the names it still spells, or None.""" + try: + target_names = flat_family_names(rule, variables, base_name) + source_names = flat_family_names(rule, variables, source_base) + except (TypeError, ValueError): + return None + if len(set(source_names)) != len(source_names): + return None + return target_names, source_names + + +def _singly_claimed(candidates): + """Return only the candidates whose members no other candidate also claims.""" + claims: dict[int, int] = {} + for _base_name, _names, members in candidates: + for member in members: + claims[member.pk] = claims.get(member.pk, 0) + 1 + return [candidate for candidate in candidates if all(claims[member.pk] == 1 for member in candidate[2])] + + +def flat_family_candidates(rule, variables, interfaces, catalog): + """Return complete, unambiguous flat-family candidates on this module. + + A flat family carries the names the rule's channel range spells, and a flat rule and the + channelized rule it later became spell those identically, so the caller decides whether the + rule's current breakout mode makes these families its own to rename or its own to convert. + """ + by_name = {interface.name: interface for interface in interfaces if is_plain_interface(interface)} + if not by_name: + return [] + candidates = [] + for base_name, source_base in flat_family_bases(rule, variables, interfaces, catalog): + names = family_names_for(rule, variables, base_name, source_base) + if names is None: + continue + target_names, source_names = names + if not all(name in by_name for name in source_names): + continue + candidate = (base_name, target_names, tuple(by_name[name] for name in source_names)) + if candidate not in candidates: # pragma: no branch - duplicates require historical matchers + candidates.append(candidate) + return _singly_claimed(candidates) + + +def _flat_candidates(rule, variables, interfaces, catalog): + """Return the flat families a flat-mode rule owns on this module.""" + if rule.breakout_mode != BreakoutModeChoices.FLAT: + return [] + return flat_family_candidates(rule, variables, interfaces, catalog) + + +def _flat_plan(module, db_alias, target_names, interfaces): + """Build one immutable plan from a complete flat-family candidate.""" + members = tuple( + PlannedMember( + snapshot=InterfaceSnapshot.from_interface(interface), + target_name=target_name, + role=MemberRole.FLAT_MEMBER, + ) + for interface, target_name in zip(interfaces, target_names, strict=True) + ) + return InstalledFamilyPlan( + family_id=f"flat:{members[0].snapshot.pk}", + topology=FamilyTopology.FLAT, + device_id=module.device_id, + module_id=module.pk, + db_alias=db_alias, + members=members, + ) + + +def _channelized_plan(device_id, module_id, db_alias, parent, children, targets): # pragma: no cover + """Build one plan for an existing channelized family from the names *targets* intends.""" + members = [ + PlannedMember( + snapshot=InterfaceSnapshot.from_interface(parent), + target_name=targets.parent_name, + role=MemberRole.PARENT, + ) + ] + members.extend( + PlannedMember( + snapshot=InterfaceSnapshot.from_interface(child), + target_name=target, + role=MemberRole.CHANNEL, + reason=reason, + ) + for child, (target, reason) in zip(children, targets.channels, strict=True) + ) + return InstalledFamilyPlan( + family_id=f"channelized:{parent.pk}", + topology=FamilyTopology.CHANNELIZED, + device_id=device_id, + module_id=module_id, + db_alias=db_alias, + members=tuple(members), + parent_pk=parent.pk, + precondition_status=targets.status, + precondition_reason=targets.reason, + ) + + +def _module_family_targets(rule, variables, parent, children, suffixes): # pragma: no cover + """Return the names *rule* intends for a channelized family a module carries.""" + return channelized_family_targets( + rule, + variables, + parent.name, + parent.channels, + tuple((child.name, child.channel_id) for child in children), + suffixes, + ) + + +def _channelized_plans(module, rule, variables, db_alias, interfaces, catalog): # pragma: no cover + """Return one plan for every structurally discovered channelized family.""" + parents = [interface for interface in interfaces if is_channelized_parent(interface)] + if not parents: + return [] + children_by_parent: dict[int, list] = {} + for interface in interfaces: + if _is_channel(interface) and interface.parent_id is not None: + children_by_parent.setdefault(interface.parent_id, []).append(interface) + suffixes = template_channel_suffixes(catalog.get()) + plans = [] + for parent in parents: + children = children_by_parent.get(parent.pk, []) + children.sort(key=lambda child: (child.channel_id, child.pk)) + targets = _module_family_targets(rule, variables, parent, children, suffixes) + plans.append(_channelized_plan(module.device_id, module.pk, db_alias, parent, children, targets)) + return plans + + +class TemplateNames: + """The module type's resolved template names, read only where a plan needs them.""" + + def __init__(self, module): + self._module = module + self._templates = None + + def get(self): + """Return every resolved template name for the module, loading them once.""" + if self._templates is None: + self._templates = resolved_template_names(self._module) + return self._templates + + +def interfaces_by_module(modules): + """Load every interface of a module batch in one query, in stable per-module order.""" + by_module: dict[int, list] = {module.pk: [] for module in modules} + if not by_module: + return by_module + rows = ( + Interface.objects.using(module_db_alias(modules[0])) + .filter(module_id__in=list(by_module)) + .order_by("module_id", "name") + ) + for interface in rows: + by_module[interface.module_id].append(interface) + return by_module + + +def device_interface_families(interfaces): + """Return each device-level base interface with its channel children.""" + children_by_parent: dict[int, list] = {} + for interface in interfaces: + if _is_channel(interface) and interface.parent_id is not None: + children_by_parent.setdefault(interface.parent_id, []).append(interface) + for children in children_by_parent.values(): + children.sort(key=lambda child: (child.channel_id, child.pk)) + return tuple( + (interface, tuple(children_by_parent.get(interface.pk, ()))) + for interface in interfaces + if not _is_channel(interface) + ) + + +def _interface_rename_plan(device_id, module_id, db_alias, rule, variables, interface) -> InstalledFamilyPlan: + """Return a plan that renames one interface which belongs to no family.""" + status, reason, target_name = None, "", interface.name + try: + target_name = evaluate_name_template(rule.name_template, {**variables, "base": interface.name}) + except (TypeError, ValueError) as error: + status, reason = FamilyStatus.FAILED, f"failed to evaluate the interface name: {error}" + return InstalledFamilyPlan( + family_id=f"flat:{interface.pk}", + topology=FamilyTopology.FLAT, + device_id=device_id, + module_id=module_id, + db_alias=db_alias, + members=( + PlannedMember( + snapshot=InterfaceSnapshot.from_interface(interface), + target_name=target_name, + role=MemberRole.FLAT_MEMBER, + ), + ), + precondition_status=status, + precondition_reason=reason, + ) + + +def plan_interface_rename(module, rule, variables, interface) -> InstalledFamilyPlan: + """Return the plan that renames one interface which belongs to no family.""" + return _interface_rename_plan( + module.device_id, + module.pk, + module_db_alias(module), + rule, + variables, + interface, + ) + + +def plan_device_interface_rename(device, rule, variables, interface, children=()) -> InstalledFamilyPlan: + """Return the plan that renames one device-level interface family.""" + db_alias = _db_alias(device) + if not children and not is_channelized_parent(interface): + return _interface_rename_plan(device.pk, None, db_alias, rule, variables, interface) + # A device rule never builds a family, so its channel count says nothing about this one: the + # members keep the suffixes they carry under whatever name the parent takes. A device-level + # interface has no module template family, so there is no suffix to recover from one either. + targets = lockstep_family_targets( + rule, variables, interface.name, tuple((child.name, child.channel_id) for child in children), {} + ) + return _channelized_plan(device.pk, None, db_alias, interface, children, targets) + + +def plan_installed_families(module, rule, variables, interfaces=None) -> InstalledFamilyPlanSet: + """Return immutable plans for the installed families owned by *module*. + + A batch that already holds the module's interface rows passes them in, so planning a fleet + reads them once rather than once per module. + """ + db_alias = module_db_alias(module) + if interfaces is None: + interfaces = list(Interface.objects.using(db_alias).filter(module_id=module.pk).order_by("pk")) + catalog = TemplateNames(module) + plans = _channelized_plans(module, rule, variables, db_alias, interfaces, catalog) + plans.extend( + _flat_plan(module, db_alias, target_names, members) + for _base_name, target_names, members in _flat_candidates(rule, variables, interfaces, catalog) + ) + plans.sort(key=lambda plan: plan.member_pks[0]) + return InstalledFamilyPlanSet(module_id=module.pk, plans=tuple(plans)) diff --git a/netbox_interface_name_rules/family/names.py b/netbox_interface_name_rules/family/names.py new file mode 100644 index 0000000..f98f1f3 --- /dev/null +++ b/netbox_interface_name_rules/family/names.py @@ -0,0 +1,97 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2025 Marcin Zieba +"""Shared live interface-name primitives for family execution.""" + +import logging + +from dcim.models import Interface +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction + +logger = logging.getLogger(__name__) + +COLLISION_REASON = "target name is already in use" +INTERFACE_NAME_CONSTRAINT = "dcim_interface_unique_device_name" + + +def name_is_taken(device_id, target_name, db_alias, exclude_pk) -> bool: + """Return whether an interface other than *exclude_pk* already owns *target_name* on the device.""" + return ( + Interface.objects.using(db_alias).filter(device_id=device_id, name=target_name).exclude(pk=exclude_pk).exists() + ) + + +def is_name_collision(error: IntegrityError) -> bool: + """Return whether PostgreSQL identified NetBox's interface-name constraint.""" + cause = error.__cause__ + diagnostics = getattr(cause, "diag", None) + return getattr(diagnostics, "constraint_name", None) == INTERFACE_NAME_CONSTRAINT + + +def restore_deferred_channel_names(reconciliations, db_alias): + """Restore plugin-owned names that NetBox's parent cascade changed after commit.""" + child_pks = [child_pk for child_pk, _final_name, _cascade_name in reconciliations] + with transaction.atomic(using=db_alias): + children = ( + Interface.objects.using(db_alias) + .select_for_update(of=("self",)) + .select_related("device") + .order_by("pk") + .in_bulk(child_pks) + ) + for child_pk, final_name, cascade_name in reconciliations: + child = children.get(child_pk) + if child is None or child.name == final_name: + continue + if child.name != cascade_name: + logger.warning( + "Channel interface %s changed to unexpected name %r before deferred reconciliation; " + "leaving it unchanged.", + child_pk, + child.name, + ) + continue + previous_name = child.name + try: + with transaction.atomic(using=db_alias): + child.name = final_name + child.full_clean() + child.save(using=db_alias) + except ValidationError: + child.name = previous_name + logger.exception( + "Failed to restore channel interface %s from NetBox's deferred name %r to %r; skipping.", + child_pk, + cascade_name, + final_name, + ) + except IntegrityError as error: + child.name = previous_name + if not is_name_collision(error): + raise + logger.warning( + "Channel interface %s could not reclaim name %r after NetBox's deferred rename; skipping.", + child_pk, + final_name, + ) + + +def reconcile_after_parent_cascade(parent_before, parent_after, channels, db_alias): + """Schedule restoration of the channel names NetBox's deferred parent cascade will overwrite. + + *channels* carries ``(child_pk, channel_id, final_name)`` for every channel the caller settled. + Registration happens on the caller's open transaction so the callback runs after NetBox's own. + """ + if parent_after == parent_before: + return + reconciliations = tuple( + (child_pk, final_name, f"{parent_after}:{channel_id}") + for child_pk, channel_id, final_name in channels + if final_name == f"{parent_before}:{channel_id}" and final_name != f"{parent_after}:{channel_id}" + ) + if not reconciliations: + return + transaction.on_commit( + lambda: restore_deferred_channel_names(reconciliations, db_alias), + using=db_alias, + ) diff --git a/netbox_interface_name_rules/family/prospective.py b/netbox_interface_name_rules/family/prospective.py new file mode 100644 index 0000000..6172fe1 --- /dev/null +++ b/netbox_interface_name_rules/family/prospective.py @@ -0,0 +1,318 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2025 Marcin Zieba +"""Plan interface families for interfaces described by name alone. + +A prospective plan says what a rule intends, using the same naming rules installed execution +follows. It carries no row snapshot, so it can describe a family NetBox has not created yet and +the executors refuse to take one. +""" + +import logging +from dataclasses import dataclass + +from ..choices import BreakoutModeChoices +from ..naming import evaluate_name_template +from .capabilities import supports_channelization +from .domain import ( + FamilyStatus, + FamilyTopology, + MemberRole, + ProspectiveFamilyPlan, + ProspectiveFamilyPlanSet, + ProspectiveMember, +) +from .names import COLLISION_REASON +from .structural import UNSUPPORTED_REASON, has_flat_expansion +from .targets import channelized_family_names, channelized_family_targets, flat_family_names, template_channel_suffixes +from .template_names import resolved_template_names + +logger = logging.getLogger(__name__) + +FLAT_EXPANSION_REASON = "the module already carries a flat breakout family" + + +@dataclass(frozen=True, slots=True) +class ProspectiveInterface: + """One interface a prospective plan reasons about, described without a row.""" + + name: str + parent_name: str | None = None + channel_id: int | None = None + channels: int | None = None + + +def describe_interfaces(interfaces) -> tuple[ProspectiveInterface, ...]: + """Describe live interface rows for prospective planning.""" + interfaces = tuple(interfaces) + names_by_pk = {interface.pk: interface.name for interface in interfaces} + return tuple( + ProspectiveInterface( + name=interface.name, + parent_name=names_by_pk.get(getattr(interface, "parent_id", None)), + channel_id=getattr(interface, "channel_id", None), + channels=getattr(interface, "channels", None), + ) + for interface in interfaces + ) + + +def describe_template_interfaces(templates, names=()) -> tuple[ProspectiveInterface, ...]: + """Describe the interfaces a module type's templates produce, plus any extra *names*. + + A channel is paired with its parent only where that parent declares a channel count, because a + template family is what the parent's capacity defines. A name no template resolves to is + described as a plain interface. + """ + parents = {template.pk: template for template in templates if template.channels is not None} + described = {} + for template in templates: + parent = parents.get(template.parent_id) if template.channel_id is not None else None + described[template.resolved] = ProspectiveInterface( + name=template.resolved, + parent_name=None if parent is None else parent.resolved, + channel_id=None if parent is None else template.channel_id, + channels=template.channels, + ) + described.update( + {name: ProspectiveInterface(name=name) for name in names if name not in described}, + ) + return tuple(described.values()) + + +def describe_module_interfaces(module, names=()) -> tuple[ProspectiveInterface, ...]: + """Describe the interfaces *module*'s templates produce, plus any extra *names*. + + A release that cannot model channelized families describes the names alone: its templates carry + no family structure, so reading them would buy nothing. + """ + if not supports_channelization(): + return tuple(ProspectiveInterface(name=name) for name in names) + return describe_template_interfaces( # pragma: no cover - requires channelization support + resolved_template_names(module), names + ) + + +def _partition(interfaces): + """Split described interfaces into ``(roots, children_by_parent_name)``. + + A channel is never an independent candidate: it belongs to the interface it names as its + parent, and one whose parent is not described here is planned by nothing at all. + """ + described = {interface.name for interface in interfaces} + roots = [] + children: dict[str, list] = {} + for interface in interfaces: + if interface.channel_id is None: + roots.append(interface) + elif interface.parent_name in described: # pragma: no cover - requires channelization support + children.setdefault(interface.parent_name, []).append(interface) + return roots, children + + +def _is_family_root(rule, interface, children): + """Return whether *interface* owns a family the rule renames as a unit. + + A breakout rule renames only a family NetBox models with a channel count; on anything else it + builds a new family from the interface itself. Any other rule carries whatever channels the + interface has along with it. + """ + if rule.channel_count > 0: + return interface.channels is not None + return bool(children) + + +def _rename_plan(rule, variables, parent, children, suffixes): # pragma: no cover - channelization only + """Plan the rename of a family the templates or rows already describe.""" + children = sorted(children, key=lambda child: child.channel_id) + targets = channelized_family_targets( + rule, + variables, + parent.name, + parent.channels, + tuple((child.name, child.channel_id) for child in children), + suffixes, + ) + members = [ProspectiveMember(source_name=parent.name, target_name=targets.parent_name, role=MemberRole.PARENT)] + members.extend( + ProspectiveMember( + # A channel whose target cannot be derived keeps the name it has. + source_name=child.name, + target_name=child.name if target is None else target, + role=MemberRole.CHANNEL, + channel_id=child.channel_id, + reason=reason, + ) + for child, (target, reason) in zip(children, targets.channels, strict=True) + ) + return ProspectiveFamilyPlan( + family_id=f"channelized:{parent.name}", + topology=FamilyTopology.CHANNELIZED, + base_name=None, + members=tuple(members), + precondition_status=targets.status, + precondition_reason=targets.reason, + ) + + +def _refused_creation(base_name, topology, role, status, reason): + """Plan a family that will not be built, so the base keeps the name it has.""" + return ProspectiveFamilyPlan( + family_id=f"{topology}:{base_name}", + topology=topology, + base_name=base_name, + members=(ProspectiveMember(source_name=base_name, target_name=base_name, role=role),), + precondition_status=status, + precondition_reason=reason, + ) + + +def _simple_plan(rule, variables, base_name): + """Plan the rename of one interface that owns no family.""" + try: + target_name = evaluate_name_template(rule.name_template, {**variables, "base": base_name}) + except (TypeError, ValueError) as error: + return _refused_creation( + base_name, FamilyTopology.FLAT, MemberRole.FLAT_MEMBER, FamilyStatus.FAILED, str(error) + ) + return ProspectiveFamilyPlan( + family_id=f"flat:{base_name}", + topology=FamilyTopology.FLAT, + base_name=base_name, + members=(ProspectiveMember(source_name=base_name, target_name=target_name, role=MemberRole.FLAT_MEMBER),), + ) + + +def _flat_creation_plan(rule, variables, base_name): + """Plan the flat sibling family a breakout rule creates on one plain interface.""" + try: + target_names = flat_family_names(rule, variables, base_name) + except (TypeError, ValueError) as error: + return _refused_creation( + base_name, FamilyTopology.FLAT, MemberRole.FLAT_MEMBER, FamilyStatus.FAILED, str(error) + ) + members = tuple( + ProspectiveMember( + source_name=base_name if offset == 0 else None, + target_name=target_name, + role=MemberRole.FLAT_MEMBER, + ) + for offset, target_name in enumerate(target_names) + ) + return ProspectiveFamilyPlan( + family_id=f"flat:{base_name}", + topology=FamilyTopology.FLAT, + base_name=base_name, + members=members, + ) + + +def _structural_refusal(base_name, flat_expansion, taken_names, target_names): # pragma: no cover - see below + """Return why the planned family cannot be built here, or None when nothing refuses it.""" + if flat_expansion: + # Converting one sibling into a parent would strand the others beside the new family. + return FLAT_EXPANSION_REASON + collisions = [name for name in target_names if name != base_name and name in taken_names] + if collisions: + return f"{COLLISION_REASON}: {collisions[0]}" + return None + + +def _modelled_structural_plan(rule, variables, base_name, context): # pragma: no cover - see below + """Plan the channelized family for a NetBox release that can hold it.""" + try: + parent_name, channels = channelized_family_names(rule, base_name, variables) + except (TypeError, ValueError) as error: + return _refused_creation( + base_name, FamilyTopology.CHANNELIZED, MemberRole.PARENT, FamilyStatus.FAILED, str(error) + ) + target_names = (parent_name, *(name for _channel_id, name in channels)) + refusal = _structural_refusal(base_name, context.flat_expansion, context.taken_names, target_names) + if refusal is not None: + return _refused_creation( + base_name, FamilyTopology.CHANNELIZED, MemberRole.PARENT, FamilyStatus.BLOCKED, refusal + ) + members = ( + ProspectiveMember(source_name=base_name, target_name=parent_name, role=MemberRole.PARENT), + *( + ProspectiveMember(source_name=None, target_name=name, role=MemberRole.CHANNEL, channel_id=channel_id) + for channel_id, name in channels + ), + ) + return ProspectiveFamilyPlan( + family_id=f"channelized:{base_name}", + topology=FamilyTopology.CHANNELIZED, + base_name=base_name, + members=members, + ) + + +def _structural_plan(rule, variables, base_name, context): + """Plan the channelized family a rule builds on one plain interface.""" + if not supports_channelization(): + return _refused_creation( + base_name, FamilyTopology.CHANNELIZED, MemberRole.PARENT, FamilyStatus.UNSUPPORTED, UNSUPPORTED_REASON + ) + return _modelled_structural_plan(rule, variables, base_name, context) # pragma: no cover - see above + + +def _plain_plan(rule, variables, base_name, context): + """Plan the family *rule* intends on one plain interface.""" + if rule.channel_count <= 0: + return _simple_plan(rule, variables, base_name) + if rule.breakout_mode == BreakoutModeChoices.CHANNELIZED: + return _structural_plan(rule, variables, base_name, context) + return _flat_creation_plan(rule, variables, base_name) + + +@dataclass(frozen=True, slots=True) +class _CreationContext: + """The module-wide facts every creation plan on one module shares.""" + + taken_names: frozenset + flat_expansion: bool + + +def _creation_context(module, rule, interfaces, plain): + """Read the module-wide facts once, and only where a creation plan can use them.""" + builds_channels = rule.channel_count > 0 and rule.breakout_mode == BreakoutModeChoices.CHANNELIZED + flat_expansion = bool(plain) and builds_channels and supports_channelization() and has_flat_expansion(module) + return _CreationContext( + taken_names=frozenset(interface.name for interface in interfaces), + flat_expansion=flat_expansion, + ) + + +class _TemplateSuffixes: + """The module type's per-channel name suffixes, read only if a channel name needs recovering.""" + + def __init__(self, module): + self._module = module + self._suffixes = None + + def get(self, channel_id, default=None): # pragma: no cover - requires channelization support + """Return the suffixes the templates give *channel_id*, reading them on first use.""" + if self._suffixes is None: + self._suffixes = template_channel_suffixes(resolved_template_names(self._module)) + return self._suffixes.get(channel_id, default) + + +def plan_prospective_families(module, rule, variables, interfaces) -> ProspectiveFamilyPlanSet: + """Return one plan for each family *rule* intends on the described *interfaces*. + + *interfaces* are ``ProspectiveInterface`` values, so a name NetBox has not created yet is + planned exactly like one it has and no row is written. Collision checking covers the names + described here, because a prospective plan knows only the interfaces it was given. + """ + roots, children = _partition(interfaces) + families = [(root, children.get(root.name, ())) for root in roots] + context = _creation_context( + module, rule, interfaces, [root for root, family in families if not _is_family_root(rule, root, family)] + ) + suffixes = _TemplateSuffixes(module) + plans = tuple( + _rename_plan(rule, variables, root, family, suffixes) + if _is_family_root(rule, root, family) + else _plain_plan(rule, variables, root.name, context) + for root, family in families + ) + return ProspectiveFamilyPlanSet(module_id=module.pk, plans=plans) diff --git a/netbox_interface_name_rules/family/structural.py b/netbox_interface_name_rules/family/structural.py new file mode 100644 index 0000000..f6fa858 --- /dev/null +++ b/netbox_interface_name_rules/family/structural.py @@ -0,0 +1,382 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2025 Marcin Zieba +"""Plan and execute the creation of interface families.""" + +import logging + +from dcim.choices import InterfaceTypeChoices +from dcim.models import Interface, InterfaceTemplate +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction + +from .capabilities import supports_channelization +from .domain import ( + FamilyOutcome, + FamilyStatus, + FamilyTopology, + FlatCreationPlan, + InterfaceSnapshot, + MemberOutcome, + PlannedChannel, + StructuralFamilyPlan, +) +from .installed import module_db_alias +from .names import COLLISION_REASON, is_name_collision, name_is_taken, reconcile_after_parent_cascade +from .targets import channelized_family_names, flat_family_names + +logger = logging.getLogger(__name__) + +UNSUPPORTED_REASON = "this NetBox release cannot model channelized interfaces" +STALE_REASON = "the base interface changed after planning" +MODULE_CHANGED_REASON = "the module's interfaces changed after planning" + + +def _flat_expansion(module_type_id, module_id, db_alias) -> bool: # pragma: no cover - channelization only + """Return whether the module carries more plain interfaces than its templates describe.""" + templates = InterfaceTemplate.objects.using(db_alias).filter(module_type_id=module_type_id).count() + plain = Interface.objects.using(db_alias).filter(module_id=module_id, channel_id__isnull=True) + return plain.count() > templates + + +def has_flat_expansion(module) -> bool: # pragma: no cover - requires channelization support + """Return whether *module* carries more plain interfaces than its module type's templates describe. + + A flat breakout leaves N-1 rows beyond the templates, so the surplus is the structural mark of a + family an earlier apply installed. A channel belongs to the parent that declares it, so it is + never part of that surplus: counting one would stop a second port from gaining its own family. + Counting templates rather than their resolved names keeps two templates that resolve to the same + string from reading as one. + """ + return _flat_expansion(module.module_type_id, module.pk, module_db_alias(module)) + + +def _plan(module, db_alias, base, parent_target_name, channels, status=None, reason=""): + """Build one immutable structural plan for *base*.""" + return StructuralFamilyPlan( + family_id=f"structural:{base.pk}", + device_id=module.device_id, + module_id=module.pk, + module_type_id=module.module_type_id, + db_alias=db_alias, + base=InterfaceSnapshot.from_interface(base), + parent_target_name=parent_target_name, + channel_count=len(channels), + channels=tuple(PlannedChannel(channel_id=channel_id, name=name) for channel_id, name in channels), + precondition_status=status, + precondition_reason=reason, + ) + + +def _modelled_plan(module, rule, variables, db_alias, base): # pragma: no cover - channelization only + """Return the plan for a NetBox release that can hold the family.""" + try: + parent_target_name, channels = channelized_family_names(rule, base.name, variables) + except (TypeError, ValueError) as error: + reason = f"failed to evaluate the family names: {error}" + return _plan(module, db_alias, base, base.name, (), FamilyStatus.FAILED, reason) + if has_flat_expansion(module): + # Converting one sibling into a parent would strand the others beside the new family. + reason = f"module {module} already carries a flat breakout family" + return _plan(module, db_alias, base, parent_target_name, channels, FamilyStatus.BLOCKED, reason) + return _plan(module, db_alias, base, parent_target_name, channels) + + +def plan_structural_family(module, rule, variables, base) -> StructuralFamilyPlan: + """Return the immutable plan for the channelized family *rule* builds on plain interface *base*.""" + db_alias = module_db_alias(module) + if not supports_channelization(): + return _plan(module, db_alias, base, base.name, (), FamilyStatus.UNSUPPORTED, UNSUPPORTED_REASON) + return _modelled_plan(module, rule, variables, db_alias, base) # pragma: no cover - see above + + +def _outcome(plan, status, members, reason=""): + """Build the immutable outcome of one structural family operation.""" + return FamilyOutcome( + family_id=plan.family_id, + topology=FamilyTopology.CHANNELIZED, + status=status, + members=members, + reason=reason, + ) + + +def _refused(plan, status, reason, target_name): + """Log why the family was not built and return an outcome that touched no row.""" + logger.warning("Cannot build a channelized family on interface %r: %s.", plan.base.name, reason) + member = MemberOutcome( + interface_pk=plan.base.pk, + current_name=plan.base.name, + target_name=target_name, + status=status, + reason=reason, + ) + return _outcome(plan, status, (member,), reason) + + +def _locked_base(plan): + """Lock and return the live base row, or None when it is gone.""" + return ( + Interface.objects.using(plan.db_alias) + .select_for_update(of=("self",)) + .select_related("device", "module") + .filter(pk=plan.base.pk) + .first() + ) + + +def _first_taken_name(plan): # pragma: no cover - requires channelization support + """Return the first planned name another interface on the device already owns, or None.""" + for target_name in plan.target_names: + if name_is_taken(plan.device_id, target_name, plan.db_alias, exclude_pk=plan.base.pk): + return target_name + return None + + +def _create_channels(plan, parent): # pragma: no cover - requires channelization support + """Create every planned channel under *parent* and return their outcomes.""" + members = [] + for channel in plan.channels: + row = Interface( + device=parent.device, + module=parent.module, + name=channel.name, + type=InterfaceTypeChoices.TYPE_CHANNEL, + parent=parent, + channel_id=channel.channel_id, + enabled=parent.enabled, + ) + row.full_clean() + row.save(using=plan.db_alias) + members.append( + MemberOutcome( + interface_pk=row.pk, + current_name=channel.name, + target_name=channel.name, + status=FamilyStatus.CHANGED, + ) + ) + return members + + +def _create_family(plan, base): # pragma: no cover - requires channelization support + """Rewrite *base* into the family parent, create its channels, and return every member outcome.""" + parent_status = FamilyStatus.CHANGED if plan.parent_target_name != base.name else FamilyStatus.UNCHANGED + base.channels = plan.channel_count + base.name = plan.parent_target_name + base.full_clean() + base.save(using=plan.db_alias) + parent_member = MemberOutcome( + interface_pk=base.pk, + current_name=plan.base.name, + target_name=plan.parent_target_name, + status=parent_status, + ) + channel_members = _create_channels(plan, base) + reconcile_after_parent_cascade( + plan.base.name, + plan.parent_target_name, + tuple( + (member.interface_pk, channel.channel_id, channel.name) + for member, channel in zip(channel_members, plan.channels, strict=True) + ), + plan.db_alias, + ) + return (parent_member, *channel_members) + + +def _install_family(plan): # pragma: no cover - requires channelization support + """Create the whole family in one transaction, or write nothing at all.""" + try: + with transaction.atomic(using=plan.db_alias): + base = _locked_base(plan) + if base is None or InterfaceSnapshot.from_interface(base) != plan.base: + return _refused(plan, FamilyStatus.STALE, STALE_REASON, plan.parent_target_name) + # A sibling added since planning would be stranded beside the family this plan builds. + if _flat_expansion(plan.module_type_id, plan.module_id, plan.db_alias): + return _refused(plan, FamilyStatus.STALE, MODULE_CHANGED_REASON, plan.parent_target_name) + taken = _first_taken_name(plan) + if taken is not None: + return _refused(plan, FamilyStatus.BLOCKED, f"{COLLISION_REASON}: {taken}", taken) + members = _create_family(plan, base) + except ValidationError as error: + return _refused(plan, FamilyStatus.BLOCKED, " ".join(error.messages), plan.parent_target_name) + except IntegrityError as error: + if not is_name_collision(error): + raise + return _refused(plan, FamilyStatus.BLOCKED, COLLISION_REASON, plan.parent_target_name) + return _outcome(plan, FamilyStatus.CHANGED, members) + + +def execute_structural_family(plan: StructuralFamilyPlan) -> FamilyOutcome: + """Create the planned channelized family, or leave every row exactly as it was. + + Only a structural plan names the base row to rewrite, so anything else (a prospective plan + above all) is refused before a single row is locked. + """ + if not isinstance(plan, StructuralFamilyPlan): + raise TypeError(f"{type(plan).__name__} is not an executable family plan") + if plan.precondition_status is not None: + return _refused(plan, plan.precondition_status, plan.precondition_reason, plan.parent_target_name) + return _install_family(plan) # pragma: no cover - requires channelization support + + +def install_channelized_family(module, rule, variables, base) -> FamilyOutcome: + """Build the channelized family *rule* describes on plain interface *base*.""" + return execute_structural_family(plan_structural_family(module, rule, variables, base)) + + +# --------------------------------------------------------------------------- +# Flat breakout families +# --------------------------------------------------------------------------- +# A flat family is N sibling interfaces on one module: the base takes the first name and the rest +# are new rows. Unlike a channelized family there is no parent to cascade from, so a sibling whose +# name is taken is skipped on its own while the family keeps the names it could take. + + +def _flat_creation_plan(module, db_alias, base, target_names, status=None, reason=""): + """Build one immutable flat-creation plan for *base*.""" + return FlatCreationPlan( + family_id=f"flat:{base.pk}", + device_id=module.device_id, + module_id=module.pk, + db_alias=db_alias, + base=InterfaceSnapshot.from_interface(base), + target_names=target_names, + precondition_status=status, + precondition_reason=reason, + ) + + +def plan_flat_family(module, rule, variables, base) -> FlatCreationPlan: + """Return the immutable plan for the flat breakout family *rule* builds on plain interface *base*.""" + db_alias = module_db_alias(module) + try: + target_names = flat_family_names(rule, variables, base.name) + except (TypeError, ValueError) as error: + reason = f"failed to evaluate the family names: {error}" + return _flat_creation_plan(module, db_alias, base, (base.name,), FamilyStatus.FAILED, reason) + return _flat_creation_plan(module, db_alias, base, target_names) + + +def _flat_outcome(plan, status, members, reason=""): + """Build the immutable outcome of one flat family operation.""" + return FamilyOutcome( + family_id=plan.family_id, + topology=FamilyTopology.FLAT, + status=status, + members=members, + reason=reason, + ) + + +def _flat_refused(plan, status, reason): + """Log why the family was not built and return an outcome that touched no row.""" + logger.warning("Cannot build a flat family on interface %r: %s.", plan.base.name, reason) + member = MemberOutcome( + interface_pk=plan.base.pk, + current_name=plan.base.name, + target_name=plan.target_names[0], + status=status, + reason=reason, + ) + return _flat_outcome(plan, status, (member,), reason) + + +def _rename_flat_base(plan, base): + """Give the base the family's first name, or report why it keeps the one it has.""" + target_name = plan.target_names[0] + if target_name == base.name: + return MemberOutcome(base.pk, base.name, target_name, FamilyStatus.UNCHANGED) + if name_is_taken(plan.device_id, target_name, plan.db_alias, exclude_pk=base.pk): + logger.warning( + "Interface name %r already exists on device %s; skipping rename of %r to %r.", + target_name, + plan.device_id, + base.name, + target_name, + ) + return MemberOutcome(base.pk, base.name, target_name, FamilyStatus.BLOCKED, COLLISION_REASON) + previous_name = base.name + base.name = target_name + base.full_clean() + base.save(using=plan.db_alias) + return MemberOutcome(base.pk, previous_name, target_name, FamilyStatus.CHANGED) + + +def _module_rows(plan): + """Return the family names this module already carries, so a re-apply creates nothing twice.""" + return dict( + Interface.objects.using(plan.db_alias) + .filter(module_id=plan.module_id, name__in=plan.target_names) + .values_list("name", "pk") + ) + + +def _create_sibling(plan, base, name): + """Create one sibling interface beside *base*, or report the name it could not take.""" + if name_is_taken(plan.device_id, name, plan.db_alias, exclude_pk=base.pk): + logger.warning( + "Interface name %r already exists on device %s; skipping the sibling of %r.", + name, + plan.device_id, + base.name, + ) + return MemberOutcome(plan.base.pk, plan.base.name, name, FamilyStatus.BLOCKED, COLLISION_REASON) + row = Interface( + device=base.device, + module=base.module, + name=name, + type=base.type, + enabled=base.enabled, + ) + row.full_clean() + row.save(using=plan.db_alias) + return MemberOutcome(row.pk, name, name, FamilyStatus.CHANGED) + + +def _build_flat_family(plan, base): + """Name the base and create every sibling the module still lacks.""" + members = [_rename_flat_base(plan, base)] + installed = _module_rows(plan) + for name in plan.target_names[1:]: + if name in installed: + members.append(MemberOutcome(installed[name], name, name, FamilyStatus.UNCHANGED)) + continue + members.append(_create_sibling(plan, base, name)) + return tuple(members) + + +def _install_flat_family(plan): + """Build the whole family in one transaction, or write nothing at all.""" + try: + with transaction.atomic(using=plan.db_alias): + base = _locked_base(plan) + if base is None or InterfaceSnapshot.from_interface(base) != plan.base: + return _flat_refused(plan, FamilyStatus.STALE, STALE_REASON) + members = _build_flat_family(plan, base) + except ValidationError as error: + return _flat_refused(plan, FamilyStatus.BLOCKED, " ".join(error.messages)) + except IntegrityError as error: + if not is_name_collision(error): + raise + return _flat_refused(plan, FamilyStatus.BLOCKED, COLLISION_REASON) + return _flat_outcome(plan, _flat_status(members), members) + + +def _flat_status(members): + """Summarize member outcomes without hiding partial success.""" + statuses = {member.status for member in members} + if FamilyStatus.CHANGED in statuses: + return FamilyStatus.CHANGED + if FamilyStatus.BLOCKED in statuses: + return FamilyStatus.BLOCKED + return FamilyStatus.UNCHANGED + + +def execute_flat_family(plan: FlatCreationPlan) -> FamilyOutcome: + """Build the planned flat breakout family, or leave every row exactly as it was.""" + if not isinstance(plan, FlatCreationPlan): + raise TypeError(f"{type(plan).__name__} is not an executable family plan") + if plan.precondition_status is not None: + return _flat_refused(plan, plan.precondition_status, plan.precondition_reason) + return _install_flat_family(plan) diff --git a/netbox_interface_name_rules/family/targets.py b/netbox_interface_name_rules/family/targets.py new file mode 100644 index 0000000..f94e1bf --- /dev/null +++ b/netbox_interface_name_rules/family/targets.py @@ -0,0 +1,214 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2025 Marcin Zieba +"""Compute the names a rule intends for one interface family. + +Every planner names a family through this module, so an installed plan, a prospective plan and a +structural plan cannot spell the same family differently. Nothing here reads or writes a row: the +inputs are names, channel identifiers and the rule. +""" + +from dataclasses import dataclass + +from ..choices import BreakoutModeChoices +from ..naming import evaluate_name_template +from .domain import FamilyStatus + +AMBIGUOUS_SUFFIX_REASON = "channel suffix is ambiguous or unavailable" + + +@dataclass(frozen=True, slots=True) +class FamilyTargets: + """The names a rule intends for one channelized family, and why it may refuse.""" + + parent_name: str + channels: tuple[tuple[str | None, str], ...] + status: FamilyStatus | None = None + reason: str = "" + + +def child_name_suffix(child_name, parent_name): # pragma: no cover - requires channelization support + """Return the suffix *child_name* adds to *parent_name*, or None when it adds none. + + The first character must be non-alphanumeric so ``et0``/``et01`` is never mistaken for a + family; the punctuation itself is free-form (``:``, ``-``, ``_`` and ``@`` all occur in the + wild), so it is not restricted to a fixed separator. + """ + if not parent_name or not child_name.startswith(parent_name): + return None + suffix = child_name[len(parent_name) :] + if not suffix or suffix[0].isalnum(): + return None + return suffix + + +def template_channel_suffixes(templates): # pragma: no cover - requires channelization support + """Map each channel identifier to the name suffixes the module type's templates give it. + + The suffix comes from the template family itself, so a child that lost its parent's prefix in + an earlier partial rename can still be repaired. A module type with several families may spell + the same channel differently in each (``et0:2`` vs ``sw0.2``), so the suffixes are collected per + channel rather than overwritten: recovery only uses one when every family agrees on it. + """ + parents = {template.pk: template.resolved for template in templates if template.channels is not None} + suffixes: dict[int, set[str]] = {} + for template in templates: + if template.channel_id is None or template.parent_id not in parents: + continue + suffix = child_name_suffix(template.resolved, parents[template.parent_id]) + if suffix is not None: + suffixes.setdefault(template.channel_id, set()).add(suffix) + return suffixes + + +def builds_channelized_family(rule) -> bool: + """Return whether *rule* builds a channelized family instead of flat sibling interfaces.""" + return rule.channel_count > 0 and rule.breakout_mode == BreakoutModeChoices.CHANNELIZED + + +def one_family_per_name_set(candidates): + """Return the index of one candidate base per family, in candidate order. + + *candidates* pairs each base name with the names the rule intends for the family built on it. + Two bases that intend the same names describe one family an earlier apply already started, so + only one of them may build it; the base the family already names is preferred, because renaming + it onto itself is the no-op the other base cannot manage. + """ + kept: dict[tuple[str, ...], int] = {} + for index, (base_name, target_names) in enumerate(candidates): + current = kept.get(target_names) + if current is None or (base_name == target_names[0] and candidates[current][0] != target_names[0]): + kept[target_names] = index + return tuple(sorted(kept.values())) + + +def flat_family_names(rule, variables, base_name): + """Return the names of the flat sibling family that *rule* defines on *base_name*.""" + family_variables = {**variables, "base": base_name} + return tuple( + evaluate_name_template( + rule.name_template, + {**family_variables, "channel": str(rule.channel_start + offset)}, + ) + for offset in range(rule.channel_count) + ) + + +def channelized_family_names(rule, base_name, variables): # pragma: no cover - channelization only + """Return ``(parent_name, ((channel_id, name), ...))`` for the family *rule* builds on *base_name*. + + ``{base}`` is the base interface's current name for the parent and every channel; ``{channel}`` + is ``channel_start + channel_id - 1``. A blank parent template leaves the base's name alone. + Takes the name rather than the interface so prediction can reuse it without a row to point at. + """ + family_variables = {**variables, "base": base_name} + parent_name = base_name + if rule.parent_name_template: + parent_name = evaluate_name_template(rule.parent_name_template, family_variables) + channels = tuple( + ( + channel_id, + evaluate_name_template( + rule.name_template, {**family_variables, "channel": str(rule.channel_start + channel_id - 1)} + ), + ) + for channel_id in range(1, rule.channel_count + 1) + ) + return parent_name, channels + + +def intended_family_names(rule, variables, base_name): + """Return every name *rule* intends for the family it builds on *base_name*. + + A base whose names cannot be evaluated is its own family: it names nothing else, so nothing + else can be grouped with it. + """ + try: + if builds_channelized_family(rule): + parent_name, channels = channelized_family_names(rule, base_name, variables) + return (parent_name, *(name for _channel_id, name in channels)) + return flat_family_names(rule, variables, base_name) + except (TypeError, ValueError): + return (base_name,) + + +def _simple_child_target(child_name, channel_id, parent_name, parent_target, suffixes): # pragma: no cover + """Return a simple rule's child target, or None plus the reason it cannot be derived.""" + suffix = child_name_suffix(child_name, parent_name) + if suffix is None: + candidates = suffixes.get(channel_id, set()) + if len(candidates) == 1: + suffix = next(iter(candidates)) + if suffix is None: + return None, AMBIGUOUS_SUFFIX_REASON + return parent_target + suffix, "" + + +def _failed(parent_name, children, error): # pragma: no cover - requires channelization support + """Return targets that leave the whole family alone because a template could not be evaluated.""" + reason = f"failed to evaluate family targets: {error}" + return FamilyTargets( + parent_name=parent_name, + channels=tuple((child_name, reason) for child_name, _channel_id in children), + status=FamilyStatus.FAILED, + reason=reason, + ) + + +def _breakout_targets(rule, variables, parent_name, parent_channels, children): # pragma: no cover + """Return the names a breakout rule intends for an existing channelized family.""" + if parent_channels != rule.channel_count: + reason = f"installed parent declares {parent_channels} channels but the rule defines {rule.channel_count}" + return FamilyTargets( + parent_name=parent_name, + channels=tuple((child_name, reason) for child_name, _channel_id in children), + status=FamilyStatus.BLOCKED, + reason=reason, + ) + try: + parent_target = parent_name + if rule.breakout_mode == BreakoutModeChoices.CHANNELIZED and rule.parent_name_template: + parent_target = evaluate_name_template(rule.parent_name_template, {**variables, "base": parent_name}) + channels = tuple( + ( + evaluate_name_template( + rule.name_template, + {**variables, "base": parent_name, "channel": str(rule.channel_start + channel_id - 1)}, + ), + "", + ) + for _child_name, channel_id in children + ) + except (TypeError, ValueError) as error: + return _failed(parent_name, children, error) + return FamilyTargets(parent_name=parent_target, channels=channels) + + +def lockstep_family_targets(rule, variables, parent_name, children, suffixes): # pragma: no cover + """Return the names a simple rule intends for a family renamed in lockstep with its parent. + + The rule's channel count says nothing here: this family already exists, and every member keeps + the suffix it carries under whatever name the parent takes. + """ + try: + parent_target = evaluate_name_template(rule.name_template, {**variables, "base": parent_name}) + except (TypeError, ValueError) as error: + return _failed(parent_name, children, error) + channels = tuple( + _simple_child_target(child_name, channel_id, parent_name, parent_target, suffixes) + for child_name, channel_id in children + ) + return FamilyTargets(parent_name=parent_target, channels=channels) + + +def channelized_family_targets( # pragma: no cover - requires channelization support + rule, variables, parent_name, parent_channels, children, suffixes +) -> FamilyTargets: + """Return the names *rule* intends for the channelized family named *parent_name*. + + *children* pairs each channel's current name with its channel identifier, in channel order. + A breakout rule renames the channels it describes; any other rule carries the family along with + its parent, keeping each channel's own suffix. + """ + if rule.channel_count > 0: + return _breakout_targets(rule, variables, parent_name, parent_channels, children) + return lockstep_family_targets(rule, variables, parent_name, children, suffixes) diff --git a/netbox_interface_name_rules/family/template_names.py b/netbox_interface_name_rules/family/template_names.py new file mode 100644 index 0000000..e48b5a2 --- /dev/null +++ b/netbox_interface_name_rules/family/template_names.py @@ -0,0 +1,189 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2025 Marcin Zieba +"""Resolve current and historical NetBox interface-template names.""" + +import contextlib +import copy +import re +import threading +from collections import namedtuple +from dataclasses import dataclass +from re import Pattern + +from dcim.models import InterfaceTemplate, Module + +from ..rule_selection import _compile_pattern + +BAY_CHAIN_RELATIONS = ( + "device", + "device__virtual_chassis", + "module_bay", + "module_bay__parent", + "module_bay__module", + "module_bay__module__module_bay", + "module_bay__module__module_bay__parent", + "module_bay__module__module_bay__module", +) + +_VC_SENTINEL = "InrVcPositionSentinel{}End" +_VC_SENTINEL_RE = re.compile(r"InrVcPositionSentinel(\d+)End") +# NetBox stores vc_position in a PositiveIntegerField, so ten digits cover every valid value. +_VC_POSITION_DIGITS = r"\d{1,10}" + +RawMatcher = namedtuple("RawMatcher", ("template_name", "resolved", "pattern")) +RawNames = namedtuple("RawNames", ("names", "matchers")) + + +@dataclass(frozen=True, slots=True) +class ResolvedTemplateName: + """One template's current name and optional historical-name matcher.""" + + pk: int + template_name: str + resolved: str + historical_pattern: Pattern[str] | None + parent_id: int | None + channel_id: int | None + channels: int | None + + +def vc_position_re(): + """Return NetBox's virtual-chassis template-token pattern when available.""" + try: + from dcim.constants import VC_POSITION_RE + except ImportError: + return None + return VC_POSITION_RE # pragma: no cover - only available on NetBox releases with the token + + +def _vc_position_alternatives(fallback): # pragma: no cover - requires virtual-chassis token support + """Return every value represented by one virtual-chassis position token.""" + if fallback is None: + return _VC_POSITION_DIGITS + return f"(?:{_VC_POSITION_DIGITS}|{re.escape(fallback)})" + + +def _historical_pattern(template, module, token_re): # pragma: no cover - requires virtual-chassis token support + """Return a matcher for every historical resolution of *template*.""" + fallbacks = [] + + def mark(match): + fallbacks.append(match.group(1)) + return _VC_SENTINEL.format(len(fallbacks) - 1) + + marked = token_re.sub(mark, template.name) + if not fallbacks: + return None + stub = copy.copy(template) + stub.name = marked + parts = _VC_SENTINEL_RE.split(re.escape(stub.resolve_name(module))) + literals = parts[0::2] + indexes = parts[1::2] + # A sentinel-shaped literal in the template name would shift these indexes; refuse to guess. + if indexes != [str(index) for index in range(len(fallbacks))]: + return None + # Adjacent tokens cannot be told apart, and their alternatives would backtrack without bound. + if any(not literal for literal in literals[1:-1]): + return None + pattern = literals[0] + for index, literal in zip(indexes, literals[1:], strict=True): + pattern += _vc_position_alternatives(fallbacks[int(index)]) + literal + return _compile_pattern(pattern) + + +# One batch of modules shares its module chains, template rows and resolved names, thread-locally. +_pin = threading.local() + + +@contextlib.contextmanager +def pinned_template_cache(modules=()): + """Share resolved interface templates across every module in one batch. + + Each module in *modules* must already carry ``BAY_CHAIN_RELATIONS``, so its templates resolve + without a refetch. Modules the block meets later are chained and cached on first use, and one + module type's template rows are read once. Nested blocks share the outermost cache. + """ + depth = getattr(_pin, "depth", 0) + _pin.depth = depth + 1 + try: + # Setting up the cache must sit inside the try, or a raise here strands the depth forever. + if depth == 0: + _pin.chained = {} + _pin.templates = {} + _pin.resolved = {} + _pin.chained.update({module.pk: module for module in modules}) + yield + finally: + _pin.depth -= 1 + if _pin.depth == 0: + for attr in ("chained", "templates", "resolved"): + _pin.__dict__.pop(attr, None) + + +def module_with_bay_chain(module): + """Re-fetch *module* with every relation template name resolution uses.""" + chained = getattr(_pin, "chained", None) + if chained is None: + return Module.objects.select_related(*BAY_CHAIN_RELATIONS).get(pk=module.pk) + if module.pk not in chained: + chained[module.pk] = Module.objects.select_related(*BAY_CHAIN_RELATIONS).get(pk=module.pk) + return chained[module.pk] + + +def _interface_templates(module_type_id): + """Return one module type's interface templates in primary-key order.""" + templates = getattr(_pin, "templates", None) + if templates is None: + return list(InterfaceTemplate.objects.filter(module_type_id=module_type_id).order_by("pk")) + if module_type_id not in templates: + templates[module_type_id] = list(InterfaceTemplate.objects.filter(module_type_id=module_type_id).order_by("pk")) + return templates[module_type_id] + + +def resolve_templates(templates, module) -> tuple[ResolvedTemplateName, ...]: + """Resolve already-loaded interface templates against *module*.""" + token_re = vc_position_re() + return tuple( + ResolvedTemplateName( + pk=template.pk, + template_name=template.name, + resolved=template.resolve_name(module), + historical_pattern=None if token_re is None else _historical_pattern(template, module, token_re), + parent_id=getattr(template, "parent_id", None), + channel_id=getattr(template, "channel_id", None), + channels=getattr(template, "channels", None), + ) + for template in templates + ) + + +def raw_names_from(templates) -> RawNames: + """Return the current names and historical matchers of already-resolved templates.""" + matchers = [ + RawMatcher(template.template_name, template.resolved, template.historical_pattern) + for template in templates + if template.historical_pattern is not None # pragma: no cover - token templates only + ] + return RawNames({template.resolved for template in templates}, matchers) + + +def raw_name_matchers(module): + """Return current and historical raw template names for *module*.""" + return raw_names_from(resolved_template_names(module)) + + +def raw_name_patterns(module): + """Return historical matchers for the module's token templates.""" + return [matcher.pattern for matcher in raw_name_matchers(module).matchers] + + +def resolved_template_names(module) -> tuple[ResolvedTemplateName, ...]: + """Load and resolve every interface template for *module* once.""" + resolved = getattr(_pin, "resolved", None) + if resolved is not None and module.pk in resolved: + return resolved[module.pk] + chained = module_with_bay_chain(module) + names = resolve_templates(_interface_templates(chained.module_type_id), chained) + if resolved is not None: + resolved[module.pk] = names + return names diff --git a/netbox_interface_name_rules/forms.py b/netbox_interface_name_rules/forms.py index 8509915..a806310 100644 --- a/netbox_interface_name_rules/forms.py +++ b/netbox_interface_name_rules/forms.py @@ -1,7 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (C) 2025 Marcin Zieba -import re - from dcim.models import DeviceType, ModuleType, Platform from django import forms from django.core.exceptions import ValidationError @@ -39,8 +37,8 @@ class RuleTestForm(forms.Form): ) module_type_pattern = forms.CharField( required=False, - label="Module Type Pattern (regex)", - help_text="Regex pattern matched against ModuleType.model via re.fullmatch()", + label="Module Type Pattern (RE2)", + help_text="RE2 pattern matched against the complete ModuleType.model value", widget=forms.TextInput(attrs={"class": "form-control"}), ) parent_module_type = forms.ModelChoiceField( @@ -139,15 +137,13 @@ def clean(self): if not module_type_pattern: self.add_error("module_type_pattern", "A regex pattern is required when regex mode is enabled.") else: + from .regex_safety import compile_module_type_pattern + try: - re.compile(module_type_pattern) - except re.error as exc: - self.add_error("module_type_pattern", f"Invalid regex: {exc}") - else: - from .models import _REDOS_PATTERN - - if _REDOS_PATTERN.search(module_type_pattern): - self.add_error("module_type_pattern", "Pattern contains potentially unsafe nested quantifiers.") + compile_module_type_pattern(module_type_pattern) + except ValidationError as exc: + for field, messages in exc.message_dict.items(): + self.add_error(field, messages) if module_type: self.add_error("module_type", "Module Type (exact) must be empty when regex mode is enabled.") else: diff --git a/netbox_interface_name_rules/jobs.py b/netbox_interface_name_rules/jobs.py index a2323d1..f8d9282 100644 --- a/netbox_interface_name_rules/jobs.py +++ b/netbox_interface_name_rules/jobs.py @@ -27,16 +27,15 @@ def run(self, *args, **kwargs): self.logger.warning("InterfaceNameRule with pk=%s does not exist; skipping.", rule_id) return - conflicts = [] try: - count = apply_rule_to_existing(rule, conflicts=conflicts) + outcome = apply_rule_to_existing(rule) except Exception as exc: self.logger.exception("Failed to apply rule '%s': %s", rule_id, exc) raise - self.logger.info("Renamed %d interface(s) using rule '%s'", count, rule) - if conflicts: - self.logger.warning("%d interface(s) skipped — the plugin log names each one.", len(conflicts)) + self.logger.info("Renamed %d interface(s) using rule '%s'", outcome.changed_count, rule) + if outcome.skipped_members: + self.logger.warning("%d interface(s) skipped. The plugin log names each one.", len(outcome.skipped_members)) class ConvertFlatFamiliesJob(JobRunner): @@ -61,13 +60,12 @@ def run(self, *args, **kwargs): self.logger.warning("InterfaceNameRule with pk=%s does not exist; skipping.", rule_id) return - conflicts = [] try: - count = convert_flat_families(rule, conflicts=conflicts) + outcome = convert_flat_families(rule) except Exception as exc: self.logger.exception("Failed to convert families for rule '%s': %s", rule_id, exc) raise - self.logger.info("Converted %d interface family(ies) using rule '%s'", count, rule) - if conflicts: - self.logger.warning("%d family(ies) skipped — the plugin log names each one.", len(conflicts)) + self.logger.info("Converted %d interface family(ies) using rule '%s'", len(outcome.changed_families), rule) + if outcome.blocked_families: + self.logger.warning("%d family(ies) skipped. The plugin log names each one.", len(outcome.blocked_families)) diff --git a/netbox_interface_name_rules/migrations/0014_validate_re2_patterns.py b/netbox_interface_name_rules/migrations/0014_validate_re2_patterns.py new file mode 100644 index 0000000..41bf19b --- /dev/null +++ b/netbox_interface_name_rules/migrations/0014_validate_re2_patterns.py @@ -0,0 +1,126 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2025 Marcin Zieba + +from django.db import migrations + +_UNICODE_SHORTHANDS = frozenset("dDsSwW") +_POSIX_CLASSES = frozenset( + { + "alnum", + "alpha", + "ascii", + "blank", + "cntrl", + "digit", + "graph", + "lower", + "print", + "punct", + "space", + "upper", + "word", + "xdigit", + } +) + + +def _is_ascii_decimal(value): + """Return whether a repetition bound contains only ASCII decimal digits.""" + return bool(value) and all("0" <= character <= "9" for character in value) + + +def _counted_repeat_uses_different_semantics(pattern, opening_index): + """Return whether Python accepts a counted repeat that RE2 treats literally.""" + closing_index = pattern.find("}", opening_index + 1) + if closing_index < 0: + return False + bounds = pattern[opening_index + 1 : closing_index] + if "," not in bounds: + return _is_ascii_decimal(bounds) and len(bounds) > 1 and bounds.startswith("0") + if bounds.count(",") != 1: + return False + lower, upper = bounds.split(",", 1) + if (lower and not _is_ascii_decimal(lower)) or (upper and not _is_ascii_decimal(upper)): + return False + return not lower or (len(lower) > 1 and lower.startswith("0")) or (len(upper) > 1 and upper.startswith("0")) + + +def _uses_different_re2_semantics(pattern): + """Return whether Python and RE2 can interpret a legacy construct differently.""" + in_character_class = False + character_class_has_content = False + index = 0 + while index < len(pattern): + character = pattern[index] + if character == "\\" and index + 1 < len(pattern): + shorthand = pattern[index + 1] + if shorthand in _UNICODE_SHORTHANDS or (shorthand in "bB" and not in_character_class): + return True + character_class_has_content = character_class_has_content or in_character_class + index += 2 + continue + if in_character_class: + if pattern.startswith("[:", index): + class_end = pattern.find(":]", index + 2) + if class_end >= 0: + class_name = pattern[index + 2 : class_end].removeprefix("^") + if class_name in _POSIX_CLASSES: + return True + if character == "]" and character_class_has_content: + in_character_class = False + elif character != "^" or character_class_has_content: + character_class_has_content = True + elif character == "[": + in_character_class = True + character_class_has_content = False + elif character == "{" and _counted_repeat_uses_different_semantics(pattern, index): + return True + elif pattern.startswith("(?", index): + flags_end = index + 2 + while flags_end < len(pattern) and pattern[flags_end] in "imsU-": + flags_end += 1 + flag_spec = pattern[index + 2 : flags_end] + enabled_flags = flag_spec.split("-", 1)[0] + if flags_end < len(pattern) and pattern[flags_end] in ":)" and "i" in enabled_flags: + return True + index += 1 + return False + + +def validate_re2_patterns(apps, schema_editor): + """Stop the upgrade before stored patterns can change meaning under RE2.""" + import re + + import re2 + + options = re2.Options() + options.log_errors = False + Rule = apps.get_model("netbox_interface_name_rules", "InterfaceNameRule") + invalid_ids = [] + rules = Rule.objects.using(schema_editor.connection.alias).exclude(module_type_pattern="") + for pk, pattern in rules.values_list("pk", "module_type_pattern").iterator(): + if _uses_different_re2_semantics(pattern): + invalid_ids.append(pk) + continue + try: + re.compile(pattern) + re2.compile(pattern, options=options) + except (OverflowError, re.error, re2.error): + invalid_ids.append(pk) + if invalid_ids: + label = "ID" if len(invalid_ids) == 1 else "IDs" + identifiers = ", ".join(str(pk) for pk in invalid_ids) + raise RuntimeError( + f"Stored patterns require RE2 review for InterfaceNameRule {label}: {identifiers}. " + "Rewrite Python-specific or semantically different shared syntax with explicit RE2 syntax, then retry the migration." + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("netbox_interface_name_rules", "0013_interfacenamerule_breakout_mode_and_more"), + ] + + operations = [ + migrations.RunPython(validate_re2_patterns, migrations.RunPython.noop), + ] diff --git a/netbox_interface_name_rules/models.py b/netbox_interface_name_rules/models.py index ce371da..61b36a1 100644 --- a/netbox_interface_name_rules/models.py +++ b/netbox_interface_name_rules/models.py @@ -11,8 +11,8 @@ from taggit.managers import TaggableManager from .choices import BreakoutModeChoices +from .regex_safety import compile_module_type_pattern -_REDOS_PATTERN = re.compile(r"(\+\*|\*\+|\?\?|\)\s*[\+\*\?]\s*[\+\*\?]|\)\s*\{[^{}]+\}\s*[\+\*\?])") _TEMPLATE_FIELD = re.compile(r"\{([^{}]*)\}") @@ -64,22 +64,6 @@ def _has_unbalanced_braces(template): return depth != 0 -def _validate_module_type_pattern(pattern): - """Compile *pattern* and check for ReDoS-prone constructs. - - Raises ``ValidationError`` targeting ``module_type_pattern`` if the - pattern is syntactically invalid or contains nested quantifiers. - Called from ``InterfaceNameRule.clean()`` to avoid duplicating the same - try/except + ReDoS guard in each branch. - """ - try: - re.compile(pattern) - except re.error as e: - raise ValidationError({"module_type_pattern": f"Invalid regex pattern: {e}"}) - if _REDOS_PATTERN.search(pattern): - raise ValidationError({"module_type_pattern": "Pattern contains potentially unsafe nested quantifiers."}) - - def _validate_breakout_topology(breakout_mode, channel_count, parent_name_template, applies_to_device_interfaces=False): """Check that the mode, the channel count and the parent template describe one topology. @@ -131,7 +115,7 @@ class InterfaceNameRule(NetBoxModel): Module type matching supports two modes: - Exact: FK reference to a specific ModuleType (default) - - Regex: Pattern matched against ModuleType.model via re.fullmatch() + - Regex: RE2 pattern matched against the complete ModuleType.model value Scoping fields (all optional): - parent_module_type: match only when installed inside this module type @@ -153,8 +137,7 @@ class InterfaceNameRule(NetBoxModel): blank=True, default="", verbose_name="Module Type Pattern", - help_text="Regex pattern to match module type model name (e.g. 'QSFP-DD-400G-.*'). " - "Uses Python re.fullmatch() — pattern must match the entire model name.", + help_text="RE2 pattern to match the complete module type model name (e.g. 'QSFP-DD-400G-.*').", ) module_type_is_regex = models.BooleanField( default=False, @@ -256,7 +239,7 @@ def clean(self): raise ValidationError({"module_type": "Module type must be empty for device-level interface rules."}) # module_type_pattern is an optional interface-name filter regex if self.module_type_pattern: - _validate_module_type_pattern(self.module_type_pattern) + compile_module_type_pattern(self.module_type_pattern) # Force regex mode off — module_type_is_regex has no meaning here self.module_type_is_regex = False elif self.module_type_is_regex: @@ -264,7 +247,7 @@ def clean(self): raise ValidationError({"module_type_pattern": "Regex pattern is required when regex mode is enabled."}) if self.module_type: raise ValidationError({"module_type": "Cannot set both module type FK and regex pattern. Choose one."}) - _validate_module_type_pattern(self.module_type_pattern) + compile_module_type_pattern(self.module_type_pattern) else: # Clear any stale pattern so it does not persist when switching modes self.module_type_pattern = "" diff --git a/netbox_interface_name_rules/naming.py b/netbox_interface_name_rules/naming.py new file mode 100644 index 0000000..5471d37 --- /dev/null +++ b/netbox_interface_name_rules/naming.py @@ -0,0 +1,144 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2025 Marcin Zieba +"""Build naming variables and evaluate interface-name templates.""" + +import ast +import operator +import re + +_BINARY_OPERATORS = { + ast.Add: operator.add, + ast.Sub: operator.sub, + ast.Mult: operator.mul, + ast.FloorDiv: operator.floordiv, +} +_UNARY_OPERATORS = { + ast.UAdd: operator.pos, + ast.USub: operator.neg, +} + + +def _evaluate_arithmetic(node): + """Evaluate one validated integer arithmetic syntax tree without executing code.""" + if isinstance(node, ast.Expression): + return _evaluate_arithmetic(node.body) + if isinstance(node, ast.Constant) and type(node.value) is int: + return node.value + if isinstance(node, ast.BinOp) and type(node.op) in _BINARY_OPERATORS: + return _BINARY_OPERATORS[type(node.op)]( + _evaluate_arithmetic(node.left), + _evaluate_arithmetic(node.right), + ) + if isinstance(node, ast.UnaryOp) and type(node.op) in _UNARY_OPERATORS: + return _UNARY_OPERATORS[type(node.op)](_evaluate_arithmetic(node.operand)) + raise ValueError(f"Unsafe AST node in expression: {type(node).__name__}") + + +def _extract_trailing_digits(value: str) -> str: + r"""Return the trailing digit run of *value* without regex backtracking. + + This O(n) string scan avoids the polynomial backtracking risk from a + trailing-digit regular expression on a long value that ends in a non-digit. + + Returns an empty string when *value* has no trailing digits. + """ + index = len(value) + while index > 0 and value[index - 1].isdigit(): + index -= 1 + return value[index:] + + +def _resolve_bay_position(module_bay): + """Return the raw and numeric positions for *module_bay*. + + A template expression such as ``{module}`` resolves from trailing digits in + the bay name. A missing numeric suffix resolves to zero. + """ + bay_position = module_bay.position or "0" + if bay_position.startswith("{"): + digits = _extract_trailing_digits(module_bay.name) + bay_position = digits if digits else "0" + digits = _extract_trailing_digits(bay_position) + bay_position_num = digits if digits else "0" + return bay_position, bay_position_num + + +def _resolve_slot(module_bay, bay_position_num, parent_bay_position): + """Return the slot value from the module-bay hierarchy. + + A nested bay takes the parent or grandparent position. A bay owned by an + installed module takes that module's bay position. Other bays use their + numeric position. + """ + if module_bay.parent: + parent_bay = module_bay.parent + if parent_bay.parent and hasattr(parent_bay.parent, "installed_module"): + return parent_bay.parent.position or parent_bay_position + return parent_bay_position + if hasattr(module_bay, "module") and module_bay.module: + owner_module = module_bay.module + if hasattr(owner_module, "module_bay") and owner_module.module_bay: + return owner_module.module_bay.position or bay_position_num + return bay_position_num + + +def build_variables(module_bay, device=None): + """Build template variables from a module bay and optional device. + + The result includes slot, bay position, numeric bay position, parent bay + position, and SFP slot. A virtual-chassis position is included only for a + member device that has a position. + + A template that uses ``{vc_position}`` for a non-member device fails during + evaluation because the variable is intentionally absent. Position zero is + retained because it is a valid virtual-chassis position. + """ + bay_position, bay_position_num = _resolve_bay_position(module_bay) + + parent_bay_position = "0" + if module_bay.parent: + parent_bay_position = module_bay.parent.position or "0" + + slot = _resolve_slot(module_bay, bay_position_num, parent_bay_position) + + result = { + "slot": slot, + "bay_position": bay_position, + "bay_position_num": bay_position_num, + "parent_bay_position": parent_bay_position, + "sfp_slot": bay_position_num, + } + if ( + device is not None + and getattr(device, "virtual_chassis_id", None) is not None + and device.vc_position is not None + ): + result["vc_position"] = str(device.vc_position) + return result + + +def evaluate_name_template(template: str, variables: dict) -> str: + """Evaluate a name template with variable substitution and safe arithmetic. + + Variables are substituted before remaining brace-enclosed arithmetic is + evaluated. True division is not allowed. Arithmetic results are converted + to integers so interface names contain whole numbers. + + For example, ``GigabitEthernet{slot}/{8 + {sfp_slot}}`` substitutes the + variables before evaluating the arithmetic expression. + """ + result = template + for key, value in variables.items(): + result = result.replace(f"{{{key}}}", str(value)) + + def _eval_expr(match): + expr = match.group(1).strip() + if not re.match(r"^(?!.*(? +"""Compile stored rule patterns with a bounded regular-expression engine.""" + +import re2 +from django.core.exceptions import ValidationError + +_OPTIONS = re2.Options() +_OPTIONS.log_errors = False + + +def _error_detail(exc): + """Return RE2's parser error as text without its bytes representation.""" + detail = exc.args[0] if exc.args else str(exc) + return detail.decode(errors="replace") if isinstance(detail, bytes) else str(detail) + + +def compile_module_type_pattern(pattern): + """Compile a stored rule pattern with RE2, or raise a field validation error.""" + try: + return re2.compile(pattern, options=_OPTIONS) + except re2.error as exc: + raise ValidationError({"module_type_pattern": f"Invalid RE2 pattern: {_error_detail(exc)}"}) from exc diff --git a/netbox_interface_name_rules/rule_selection.py b/netbox_interface_name_rules/rule_selection.py new file mode 100644 index 0000000..3b7c359 --- /dev/null +++ b/netbox_interface_name_rules/rule_selection.py @@ -0,0 +1,253 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (C) 2025 Marcin Zieba +"""Select an enabled interface-name rule for a module context.""" + +import contextlib +import threading + +from django.core.exceptions import ValidationError +from django.db.models import Aggregate, F, TextField, Value +from django.db.models.functions import Cast, Coalesce, Concat, Length + +from .regex_safety import compile_module_type_pattern + +# Publish each loaded rule set as one new dictionary. Concurrent readers then see +# one complete version rather than a mixture of cache entries from two versions. +_RULE_CACHE = {"version": None, "exact": (), "regex": (), "memo": {}} + +# Bound the number of module and scope contexts retained for one rule-set version. +_MEMO_MAX = 4096 +_MEMO_MISS = object() + +# A pinned batch owns a thread-local rule-set snapshot and a private memo. +_pin = threading.local() + + +@contextlib.contextmanager +def pinned_rule_cache(): + """Pin one enabled-rule snapshot for all selections inside the block. + + The first selection loads and fingerprints the rule set. Later selections in + the same thread skip the fingerprint query. Nested blocks share the snapshot. + The pin is thread-local, and an empty block does not load rules. + """ + depth = getattr(_pin, "depth", 0) + _pin.depth = depth + 1 + if depth == 0: + _pin.primed = False + try: + yield + finally: + _pin.depth -= 1 + if _pin.depth == 0: + _pin.primed = False + for attr in ("exact", "regex", "memo"): + _pin.__dict__.pop(attr, None) + + +def _compile_pattern(pattern): + """Compile a stored pattern once, or return None when RE2 rejects it.""" + try: + return compile_module_type_pattern(pattern) + except ValidationError: + return None + + +# These fields can change either matching or the selected rule's output. The row +# identity prevents compensating edits across two rules from preserving the hash. +_VERSION_COLUMNS = ( + "id", + "module_type_id", + "module_type_is_regex", + "module_type_pattern", + "parent_module_type_id", + "device_type_id", + "platform_id", + "name_template", + "parent_name_template", + "breakout_mode", + "channel_count", + "channel_start", + "applies_to_device_interfaces", +) + + +class _Md5OrderedStringAgg(Aggregate): + """Build ``md5(string_agg(, ORDER BY id))``.""" + + function = "STRING_AGG" + template = "MD5(%(function)s(%(expressions)s ORDER BY id))" + output_field = TextField() + + +def _version_row_signature(): + """Build an unambiguous, length-prefixed signature for one rule row.""" + empty = Value("", output_field=TextField()) + colon = Value(":", output_field=TextField()) + parts = [] + for column in _VERSION_COLUMNS: + cast = Cast(F(column), output_field=TextField()) + value = Coalesce(cast, empty, output_field=TextField()) if column.endswith("_id") else cast + parts.append(Cast(Length(value), output_field=TextField())) + parts.append(colon) + parts.append(value) + return Concat(*parts, output_field=TextField()) + + +_ROW_SIGNATURE = _version_row_signature() + + +def _enabled_rules_version(): + """Return a deterministic content fingerprint of all enabled rules. + + PostgreSQL hashes the matching and output columns in primary-key order. Each + value is length-prefixed, so arbitrary text cannot create field or row boundary + collisions. The empty rule set has a stable empty fingerprint. + """ + from .models import InterfaceNameRule + + return InterfaceNameRule.objects.filter(enabled=True).aggregate( + fingerprint=Coalesce( + _Md5OrderedStringAgg(_ROW_SIGNATURE, Value("", output_field=TextField())), + Value("", output_field=TextField()), + ) + )["fingerprint"] + + +def _get_enabled_rules(): + """Return the exact rules, regex rules, and memo for the current version. + + Exact rules retain model ordering, which reduces to primary-key order for one + module type. Regex rules are compiled once and ordered by decreasing pattern + length, then primary key. A reload publishes one new cache dictionary so a + concurrent reader cannot combine values from two versions. + """ + global _RULE_CACHE + + pinned = getattr(_pin, "depth", 0) > 0 + if pinned and getattr(_pin, "primed", False): + # Return the thread's snapshot. Another thread can replace the shared cache. + return _pin.exact, _pin.regex, _pin.memo + + from .models import InterfaceNameRule + + cache = _RULE_CACHE + version = _enabled_rules_version() + if cache["version"] != version: + rules = list(InterfaceNameRule.objects.filter(enabled=True).order_by("module_type__model", "pk")) + exact = tuple(rule for rule in rules if not rule.module_type_is_regex) + regex_rules = sorted( + (rule for rule in rules if rule.module_type_is_regex), + key=lambda rule: (-len(rule.module_type_pattern or ""), rule.pk), + ) + regex = tuple((_compile_pattern(rule.module_type_pattern), rule) for rule in regex_rules) + cache = {"version": version, "exact": exact, "regex": regex, "memo": {}} + _RULE_CACHE = cache + + if pinned: + # Keep a private memo so another thread cannot clear this batch's entries. + _pin.exact = cache["exact"] + _pin.regex = cache["regex"] + _pin.memo = dict(cache["memo"]) + _pin.primed = True + return _pin.exact, _pin.regex, _pin.memo + + return cache["exact"], cache["regex"], cache["memo"] + + +def _scope_ids(parent_module_type, device_type, platform): + """Map optional scope objects to their foreign-key values.""" + return ( + parent_module_type.pk if parent_module_type is not None else None, + device_type.pk if device_type is not None else None, + platform.pk if platform is not None else None, + ) + + +def _rule_scope_matches(rule, scope_ids): + """Return whether a rule has exactly the requested scope values.""" + parent_module_type_id, device_type_id, platform_id = scope_ids + return ( + rule.parent_module_type_id == parent_module_type_id + and rule.device_type_id == device_type_id + and rule.platform_id == platform_id + ) + + +def _build_candidates(parent_module_type, device_type, platform) -> list: + """Build scope combinations from most specific to least specific.""" + seen: set = set() + candidates = [] + parent_options = [parent_module_type, None] if parent_module_type else [None] + device_options = [device_type, None] if device_type else [None] + platform_options = [platform, None] if platform else [None] + for parent in parent_options: + for device in device_options: + for candidate_platform in platform_options: + candidate = (parent, device, candidate_platform) + if candidate not in seen: + seen.add(candidate) + candidates.append(candidate) + return candidates + + +def _find_exact_match(module_type, candidates, exact_rules=None): + """Return the first enabled exact rule in scope-precedence order.""" + if exact_rules is None: + exact_rules, _, _ = _get_enabled_rules() + + scoped_rules = [rule for rule in exact_rules if rule.module_type_id == module_type.pk] + for candidate in candidates: + scope_ids = _scope_ids(*candidate) + for rule in scoped_rules: + if _rule_scope_matches(rule, scope_ids): + return rule + return None + + +def _find_regex_match(model_name: str, candidates, regex_rules=None): + """Return the first enabled regex rule in scope-precedence order.""" + if regex_rules is None: + _, regex_rules, _ = _get_enabled_rules() + + for candidate in candidates: + scope_ids = _scope_ids(*candidate) + for compiled, rule in regex_rules: + if compiled is not None and _rule_scope_matches(rule, scope_ids) and compiled.fullmatch(model_name): + return rule + return None + + +def find_matching_rule(module_type, parent_module_type, device_type, platform=None): + """Return the most specific enabled rule for a module and scope context. + + Exact module-type rules take priority over regular-expression rules. Within + each tier, parent module type, device type, and platform scopes are tried from + most specific to least specific. + """ + if module_type is None: + return None + + exact_rules, regex_rules, memo = _get_enabled_rules() + signature = ( + module_type.pk, + # Regex matching reads the live model name, so the memo key must include it. + module_type.model, + *_scope_ids(parent_module_type, device_type, platform), + ) + # A single dictionary read cannot race with another thread's memo clear between + # a membership check and a later subscript. + cached = memo.get(signature, _MEMO_MISS) + if cached is not _MEMO_MISS: + return cached + + candidates = _build_candidates(parent_module_type, device_type, platform) + result = _find_exact_match(module_type, candidates, exact_rules) or _find_regex_match( + module_type.model, + candidates, + regex_rules, + ) + if len(memo) >= _MEMO_MAX: + memo.clear() + memo[signature] = result + return result diff --git a/netbox_interface_name_rules/signals.py b/netbox_interface_name_rules/signals.py index 8255850..6a15f45 100644 --- a/netbox_interface_name_rules/signals.py +++ b/netbox_interface_name_rules/signals.py @@ -189,7 +189,7 @@ def _apply_rules_for_device_deferred(device_pk): device_pk: Primary key of the Device to re-apply rules for. """ - from dcim.models import Device, Module + from dcim.models import Device try: device = Device.objects.select_related("virtual_chassis").get(pk=device_pk) @@ -198,35 +198,10 @@ def _apply_rules_for_device_deferred(device_pk): total = 0 - # Re-apply module interface rules - modules = Module.objects.filter(device=device).select_related( - "module_bay", - "module_type", - "device", - "device__device_type", - "device__platform", - "device__virtual_chassis", - ) try: - from .engine import apply_interface_name_rules, pinned_rule_cache - - # Every module on this device matches against the same enabled-rule set, so pin it for the - # loop: the fingerprint is read once when the first lookup primes the cache instead of once - # per module. Safe because the loop only renames interfaces — it never edits rules. - with pinned_rule_cache(): - for module in modules: - module_bay = module.module_bay - if not module_bay: - continue - try: - renamed = apply_interface_name_rules(module, module_bay, force_reapply=True) - total += renamed or 0 - except Exception: - logger.exception( - "Failed to re-apply rules for %s in %s after VC change", - module.module_type, - module_bay.name, - ) + from .engine import reapply_module_rules + + total += reapply_module_rules(device) except Exception: logger.exception("Failed to re-apply module rules for device %s after VC change", device_pk) diff --git a/netbox_interface_name_rules/templates/netbox_interface_name_rules/rule_apply_detail.html b/netbox_interface_name_rules/templates/netbox_interface_name_rules/rule_apply_detail.html index a510628..5446e5a 100644 --- a/netbox_interface_name_rules/templates/netbox_interface_name_rules/rule_apply_detail.html +++ b/netbox_interface_name_rules/templates/netbox_interface_name_rules/rule_apply_detail.html @@ -259,9 +259,9 @@
Convert flat {% if verdict.convertible %} - Convertible + {{ verdict.status_label }} {% else %} - Blocked + {{ verdict.status_label }} {% endif %} @@ -280,16 +280,20 @@
Convert flat {% if can_apply %} - + {% if conversions_available or conversions_have_more %} + + {% endif %} {% else %}