├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── 01-feature_request.yaml │ ├── 02-bug_report.yaml │ ├── 03-documentation_change.yaml │ └── config.yaml ├── pull_request_labeler.yaml └── workflows │ ├── labeler.yaml │ ├── lint-tests.yml │ ├── manifest-modified.yaml │ └── release.yaml ├── .gitignore ├── LICENSE.md ├── Makefile ├── README.md ├── SECURITY.md ├── docker ├── Dockerfile-diode-netbox-plugin ├── docker-compose.test.yaml ├── docker-compose.yaml ├── netbox │ ├── configuration │ │ ├── configuration.py │ │ ├── extra.py │ │ ├── ldap │ │ │ ├── extra.py │ │ │ └── ldap_config.py │ │ ├── logging.py │ │ └── plugins.py │ ├── docker-entrypoint.sh │ ├── env │ │ ├── netbox.env │ │ ├── postgres.env │ │ ├── redis-cache.env │ │ └── redis.env │ ├── launch-netbox.sh │ ├── local_settings.py │ ├── nginx-unit.json │ ├── plugins_dev.py │ └── plugins_test.py ├── oauth2 │ └── secrets │ │ └── .gitkeep └── requirements-diode-netbox-plugin.txt ├── netbox-plugin.yaml ├── netbox_diode_plugin ├── __init__.py ├── api │ ├── __init__.py │ ├── applier.py │ ├── authentication.py │ ├── common.py │ ├── differ.py │ ├── matcher.py │ ├── permissions.py │ ├── plugin_utils.py │ ├── serializers.py │ ├── supported_models.py │ ├── transformer.py │ ├── urls.py │ └── views.py ├── client.py ├── diode │ └── clients.py ├── forms.py ├── migrations │ ├── 0001_squashed_0005.py │ └── __init__.py ├── models.py ├── navigation.py ├── plugin_config.py ├── tables.py ├── templates │ └── diode │ │ ├── client_credential_add.html │ │ ├── client_credential_delete.html │ │ ├── client_credential_list.html │ │ ├── client_credential_secret.html │ │ ├── htmx │ │ └── delete_form.html │ │ ├── settings.html │ │ └── settings_edit.html ├── tests │ ├── __init__.py │ ├── test_api_apply_change_set.py │ ├── test_api_diff_and_apply.py │ ├── test_api_generate_diff.py │ ├── test_authentication.py │ ├── test_diode_clients.py │ ├── test_forms.py │ ├── test_models.py │ ├── test_plugin_config.py │ ├── test_updates.py │ ├── test_updates_cases.json │ ├── test_version.py │ └── test_views.py ├── urls.py ├── version.py └── views.py └── pyproject.toml /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @leoparente @ltucker @mfiedorowicz 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/01-feature_request.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: ✨ Feature Request 3 | description: Propose a new Diode NetBox Plugin feature or enhancement 4 | labels: ["enhancement", "status: needs triage"] 5 | body: 6 | - type: input 7 | attributes: 8 | label: Diode NetBox Plugin version 9 | description: What version of Diode NetBox Plugin are you currently running? 10 | placeholder: v0.6.0 11 | validations: 12 | required: true 13 | - type: input 14 | attributes: 15 | label: NetBox version 16 | description: What version of NetBox are you currently running? 17 | placeholder: v4.1.3 18 | validations: 19 | required: true 20 | - type: dropdown 21 | attributes: 22 | label: Feature type 23 | options: 24 | - New functionality 25 | - Change to existing functionality 26 | validations: 27 | required: true 28 | - type: textarea 29 | attributes: 30 | label: Proposed feature or enhancement 31 | description: > 32 | Describe in detail the new feature or enhancement you are proposing. The more detail you provide here, 33 | the greater chance your proposal has of being discussed. 34 | validations: 35 | required: true 36 | - type: textarea 37 | attributes: 38 | label: Use case 39 | description: > 40 | Explain how adding this feature or enhancement would benefit Diode users. What need does it address? 41 | validations: 42 | required: true 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/02-bug_report.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Bug Report 3 | description: Report a reproducible bug in the current release of Diode NetBox Plugin 4 | labels: ["bug", "status: needs triage"] 5 | body: 6 | - type: input 7 | attributes: 8 | label: Diode NetBox Plugin version 9 | description: What version of Diode NetBox Plugin are you currently running? 10 | placeholder: v0.6.0 11 | validations: 12 | required: true 13 | - type: input 14 | attributes: 15 | label: NetBox version 16 | description: What version of NetBox are you currently running? 17 | placeholder: v4.1.3 18 | validations: 19 | required: true 20 | - type: input 21 | attributes: 22 | label: Diode version 23 | description: What version of Diode are you currently running? 24 | placeholder: v0.6.0 25 | validations: 26 | required: true 27 | - type: dropdown 28 | attributes: 29 | label: Diode SDK type 30 | description: What type of Diode SDK are you currently running? 31 | options: 32 | - diode-sdk-python 33 | - diode-sdk-go 34 | validations: 35 | required: true 36 | - type: input 37 | attributes: 38 | label: Diode SDK version 39 | description: What version of Diode SDK are you currently running? 40 | placeholder: v0.4.0 41 | validations: 42 | required: true 43 | - type: textarea 44 | attributes: 45 | label: Steps to reproduce 46 | description: > 47 | Describe in detail the exact steps that someone else can take to reproduce this bug using given Diode NetBox 48 | Plugin, NetBox, Diode and Diode SDK versions. 49 | validations: 50 | required: true 51 | - type: textarea 52 | attributes: 53 | label: Expected behavior 54 | description: What did you expect to happen? 55 | validations: 56 | required: true 57 | - type: textarea 58 | attributes: 59 | label: Observed behavior 60 | description: What happened instead? 61 | validations: 62 | required: true 63 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/03-documentation_change.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 📖 Documentation Change 3 | description: Suggest an addition or modification to the Diode NetBox Plugin documentation 4 | labels: ["documentation", "status: needs triage"] 5 | body: 6 | - type: dropdown 7 | attributes: 8 | label: Change type 9 | description: What type of change are you proposing? 10 | options: 11 | - Addition 12 | - Correction 13 | - Removal 14 | - Cleanup (formatting, typos, etc.) 15 | validations: 16 | required: true 17 | - type: dropdown 18 | attributes: 19 | label: Area 20 | description: To what section of the documentation does this change primarily pertain? 21 | options: 22 | - Features 23 | - Installation/upgrade 24 | - Getting started 25 | - Configuration 26 | - Development 27 | - Other 28 | validations: 29 | required: true 30 | - type: textarea 31 | attributes: 32 | label: Proposed changes 33 | description: Describe the proposed changes and why they are necessary. 34 | validations: 35 | required: true 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yaml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/pull_request_labeler.yaml: -------------------------------------------------------------------------------- 1 | documentation: 2 | - changed-files: 3 | - any-glob-to-any-file: 4 | - '**/docs/**/*' 5 | 6 | github-actions: 7 | - changed-files: 8 | - any-glob-to-any-file: 9 | - '**/.github/workflows/*' 10 | - '**/.github/workflows/**/*' 11 | - '**/.github/dependabot.yaml' 12 | - '**/.github/pull_request_labeler.yaml' 13 | 14 | github-templates: 15 | - changed-files: 16 | - any-glob-to-any-file: 17 | - '**/.github/ISSUE_TEMPLATE/*' 18 | - '**/.github/PULL_REQUEST_TEMPLATE.md' 19 | - '**/.github/.chglog/*' 20 | - '**/.github/.chglog/**/*' 21 | 22 | internal: 23 | - changed-files: 24 | - any-glob-to-any-file: 25 | - '**/.flake8' 26 | - '**/.bandit.baseline' 27 | - '**/.gitignore' 28 | - '**/.pre-commit-config.yaml' 29 | - '**/MANIFEST.in' 30 | - '**/Makefile' 31 | - '**/CONTRIBUTING.md' 32 | - '**/MAINTAINERS.md' 33 | - '**/CODE_OF_CONDUCT.md' 34 | - '**/LICENSE' 35 | - '**/LICENSE.txt' 36 | - '**/THIRD-PARTY-LICENSES' 37 | - '**/.dockerignore' 38 | - '**/.editorconfig' 39 | - '**/setup.cfg' 40 | 41 | dependencies: 42 | - changed-files: 43 | - any-glob-to-any-file: 44 | - '**/pyproject.toml' 45 | - '**/poetry.lock' 46 | - '**/requirements.txt' 47 | 48 | markdown: 49 | - changed-files: 50 | - any-glob-to-any-file: '**/*.md' 51 | 52 | python: 53 | - changed-files: 54 | - any-glob-to-any-file: '**/*.py' 55 | -------------------------------------------------------------------------------- /.github/workflows/labeler.yaml: -------------------------------------------------------------------------------- 1 | name: PR labeler 2 | on: 3 | - pull_request_target 4 | 5 | concurrency: 6 | group: ${{ github.workflow }} 7 | cancel-in-progress: false 8 | 9 | jobs: 10 | triage: 11 | permissions: 12 | contents: read 13 | pull-requests: write 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 5 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/labeler@v5 19 | with: 20 | configuration-path: '.github/pull_request_labeler.yaml' 21 | -------------------------------------------------------------------------------- /.github/workflows/lint-tests.yml: -------------------------------------------------------------------------------- 1 | name: Lint and tests 2 | on: 3 | workflow_dispatch: 4 | pull_request: 5 | paths: 6 | - "netbox_diode_plugin/**" 7 | push: 8 | branches: 9 | - "!release" 10 | paths: 11 | - "netbox_diode_plugin/**" 12 | 13 | concurrency: 14 | group: ${{ github.workflow }} 15 | cancel-in-progress: false 16 | 17 | permissions: 18 | contents: write 19 | checks: write 20 | pull-requests: write 21 | 22 | jobs: 23 | tests: 24 | runs-on: ubuntu-latest 25 | timeout-minutes: 10 26 | strategy: 27 | matrix: 28 | python: [ "3.10" ] 29 | steps: 30 | - name: Checkout 31 | uses: actions/checkout@v4 32 | - name: Setup Python 33 | uses: actions/setup-python@v5 34 | with: 35 | python-version: ${{ matrix.python }} 36 | - name: Install dependencies 37 | run: | 38 | python -m pip install --upgrade pip 39 | pip install . 40 | pip install .[dev] 41 | pip install .[test] 42 | - name: Lint with Ruff 43 | run: | 44 | ruff check --output-format=github netbox_diode_plugin/ 45 | continue-on-error: true 46 | - name: Test 47 | run: | 48 | make docker-compose-netbox-plugin-test-cover 49 | - name: Coverage comment 50 | uses: orgoro/coverage@3f13a558c5af7376496aa4848bf0224aead366ac # v3.2 51 | if: github.event.pull_request.head.repo.full_name == github.repository 52 | with: 53 | coverageFile: ./docker/coverage/report.xml 54 | token: ${{ secrets.GITHUB_TOKEN }} 55 | 56 | -------------------------------------------------------------------------------- /.github/workflows/manifest-modified.yaml: -------------------------------------------------------------------------------- 1 | name: NetBox plugin manifest modified 2 | 3 | on: 4 | push: 5 | branches: [ develop ] 6 | paths: 7 | - netbox-plugin.yaml 8 | 9 | concurrency: 10 | group: ${{ github.workflow }} 11 | cancel-in-progress: false 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | manifest-modified: 18 | uses: netboxlabs/public-workflows/.github/workflows/reusable-plugin-manifest-modified.yml@release 19 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: [ release ] 6 | 7 | concurrency: 8 | group: ${{ github.workflow }} 9 | cancel-in-progress: false 10 | 11 | env: 12 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 13 | SEMANTIC_RELEASE_PACKAGE: ${{ github.repository }} 14 | PYTHON_RUNTIME_VERSION: "3.11" 15 | APP_NAME: diode-netbox-plugin 16 | PYTHON_PACKAGE_NAME: netboxlabs_diode_netbox_plugin 17 | 18 | permissions: 19 | id-token: write 20 | contents: write 21 | 22 | jobs: 23 | get-python-package-name: 24 | name: Get Python package name 25 | runs-on: ubuntu-latest 26 | timeout-minutes: 5 27 | steps: 28 | - uses: actions/checkout@v4 29 | - name: Python package name 30 | id: python-package-name 31 | run: echo "python-package-name=${{ env.PYTHON_PACKAGE_NAME }}" >> "$GITHUB_OUTPUT" 32 | outputs: 33 | python-package-name: ${{ steps.python-package-name.outputs.python-package-name }} 34 | 35 | get-next-version: 36 | name: Semantic release get next version 37 | runs-on: ubuntu-latest 38 | timeout-minutes: 5 39 | steps: 40 | - uses: actions/checkout@v4 41 | - uses: actions/setup-node@v4 42 | with: 43 | node-version: "lts/*" 44 | - name: Write package.json 45 | uses: DamianReeves/write-file-action@6929a9a6d1807689191dcc8bbe62b54d70a32b42 # v1.3 46 | with: 47 | path: ./package.json 48 | write-mode: overwrite 49 | contents: | 50 | { 51 | "name": "${{ env.APP_NAME }}", 52 | "version": "1.0.0", 53 | "devDependencies": { 54 | "semantic-release-export-data": "^1.0.1", 55 | "@semantic-release/changelog": "^6.0.3" 56 | } 57 | } 58 | - name: Write .releaserc.json 59 | uses: DamianReeves/write-file-action@6929a9a6d1807689191dcc8bbe62b54d70a32b42 # v1.3 60 | with: 61 | path: ./.releaserc.json 62 | write-mode: overwrite 63 | contents: | 64 | { 65 | "branches": "release", 66 | "repositoryUrl": "https://github.com/netboxlabs/diode-netbox-plugin", 67 | "debug": "true", 68 | "tagFormat": "v${version}", 69 | "plugins": [ 70 | ["semantic-release-export-data"], 71 | ["@semantic-release/commit-analyzer", { 72 | "releaseRules": [ 73 | { "message": "*", "release": "patch"}, 74 | { "message": "fix*", "release": "patch" }, 75 | { "message": "feat*", "release": "minor" }, 76 | { "message": "perf*", "release": "major" } 77 | ] 78 | }], 79 | "@semantic-release/release-notes-generator", 80 | [ 81 | "@semantic-release/changelog", 82 | { 83 | "changelogFile": "CHANGELOG.md", 84 | "changelogTitle": "# Semantic Versioning Changelog" 85 | } 86 | ], 87 | [ 88 | "@semantic-release/github", 89 | { 90 | "assets": [ 91 | { 92 | "path": "release/**" 93 | } 94 | ] 95 | } 96 | ] 97 | ] 98 | } 99 | - name: setup semantic-release 100 | run: npm i 101 | - name: release dry-run 102 | env: 103 | SLACK_WEBHOOK: ${{ secrets.SLACK_SEMANTIC_RELEASE_WEBHOOK }} 104 | run: npx semantic-release --debug --dry-run 105 | id: get-next-version 106 | - name: Set short sha output 107 | id: short-sha 108 | run: echo "::set-output name=short-sha::${GITHUB_SHA::7}" 109 | - name: Set release version 110 | id: release-version 111 | env: 112 | NEXT_RELEASE_VERSION: ${{ steps.get-next-version.outputs.new-release-version }} 113 | run: | 114 | echo "release-version=`echo $NEXT_RELEASE_VERSION | sed 's/v//g'`" >> $GITHUB_OUTPUT 115 | outputs: 116 | new-release-published: ${{ steps.get-next-version.outputs.new-release-published }} 117 | new-release-version: ${{ steps.release-version.outputs.release-version }} 118 | short-sha: ${{ steps.short-sha.outputs.short-sha }} 119 | 120 | confirm-version: 121 | name: Next version ${{ needs.get-next-version.outputs.new-release-version }} 122 | runs-on: ubuntu-latest 123 | timeout-minutes: 5 124 | needs: get-next-version 125 | if: needs.get-next-version.outputs.new-release-published == 'true' 126 | steps: 127 | - uses: actions/checkout@v4 128 | - name: Confirm version 129 | env: 130 | NEXT_RELEASE_VERSION: ${{ needs.get-next-version.outputs.new-release-version }} 131 | NEXT_RELEASE_SHORT_SHA: ${{ needs.get-next-version.outputs.short-sha }} 132 | run: echo "The new release version is $NEXT_RELEASE_VERSION commit $NEXT_RELEASE_SHORT_SHA" 133 | 134 | build: 135 | name: Build 136 | needs: [ get-python-package-name, get-next-version ] 137 | runs-on: ubuntu-latest 138 | timeout-minutes: 5 139 | env: 140 | BUILD_VERSION: ${{ needs.get-next-version.outputs.new-release-version }} 141 | BUILD_TRACK: release 142 | BUILD_COMMIT: ${{ needs.get-next-version.outputs.short-sha }} 143 | OUTPUT_FILENAME: ${{ needs.get-python-package-name.outputs.python-package-name }}-${{ needs.get-next-version.outputs.new-release-version }}.tar.gz 144 | steps: 145 | - uses: actions/checkout@v3 146 | - uses: actions/setup-python@v5 147 | with: 148 | python-version: ${{ env.PYTHON_RUNTIME_VERSION }} 149 | - name: Insert version variables into Python 150 | run: | 151 | sed -i "s/__commit_hash__ = .*/__commit_hash__ = \"${BUILD_COMMIT}\"/" netbox_diode_plugin/version.py 152 | sed -i "s/__track__ = .*/__track__ = \"${BUILD_TRACK}\"/" netbox_diode_plugin/version.py 153 | sed -i "s/__version__ = .*/__version__ = \"${BUILD_VERSION}\"/" netbox_diode_plugin/version.py 154 | - name: Display contents of version.py 155 | run: cat netbox_diode_plugin/version.py 156 | - name: Build sdist package 157 | run: | 158 | pip install toml-cli 159 | toml set --toml-path pyproject.toml project.version ${{ env.BUILD_VERSION }} 160 | cat pyproject.toml | grep version 161 | python3 -m pip install --upgrade build 162 | python3 -m build --sdist --outdir dist/ 163 | - name: Upload artifact 164 | uses: actions/upload-artifact@v4 165 | with: 166 | name: ${{ env.OUTPUT_FILENAME }} 167 | path: dist/${{ env.OUTPUT_FILENAME }} 168 | retention-days: 30 169 | if-no-files-found: error 170 | - name: Publish release distributions to PyPI 171 | uses: pypa/gh-action-pypi-publish@67339c736fd9354cd4f8cb0b744f2b82a74b5c70 # v.1.12.3 172 | with: 173 | packages-dir: dist 174 | 175 | semantic-release: 176 | name: Semantic release 177 | needs: [ build ] 178 | runs-on: ubuntu-latest 179 | timeout-minutes: 5 180 | steps: 181 | - uses: actions/checkout@v4 182 | - uses: actions/setup-node@v4 183 | with: 184 | node-version: "lts/*" 185 | - name: Write package.json 186 | uses: DamianReeves/write-file-action@6929a9a6d1807689191dcc8bbe62b54d70a32b42 # v1.3 187 | with: 188 | path: ./package.json 189 | write-mode: overwrite 190 | contents: | 191 | { 192 | "name": "${{ env.APP_NAME }}", 193 | "version": "1.0.0", 194 | "devDependencies": { 195 | "semantic-release-export-data": "^1.0.1", 196 | "@semantic-release/changelog": "^6.0.3" 197 | } 198 | } 199 | - name: Write .releaserc.json 200 | uses: DamianReeves/write-file-action@6929a9a6d1807689191dcc8bbe62b54d70a32b42 # v1.3 201 | with: 202 | path: ./.releaserc.json 203 | write-mode: overwrite 204 | contents: | 205 | { 206 | "branches": "release", 207 | "repositoryUrl": "https://github.com/netboxlabs/diode-netbox-plugin", 208 | "debug": "true", 209 | "tagFormat": "v${version}", 210 | "plugins": [ 211 | ["semantic-release-export-data"], 212 | ["@semantic-release/commit-analyzer", { 213 | "releaseRules": [ 214 | { "message": "*", "release": "patch"}, 215 | { "message": "fix*", "release": "patch" }, 216 | { "message": "feat*", "release": "minor" }, 217 | { "message": "perf*", "release": "major" } 218 | ] 219 | }], 220 | "@semantic-release/release-notes-generator", 221 | [ 222 | "@semantic-release/changelog", 223 | { 224 | "changelogFile": "CHANGELOG.md", 225 | "changelogTitle": "# Semantic Versioning Changelog" 226 | } 227 | ], 228 | [ 229 | "@semantic-release/github", 230 | { 231 | "assets": [ 232 | { 233 | "path": "release/**" 234 | } 235 | ] 236 | } 237 | ] 238 | ] 239 | } 240 | - name: setup semantic-release 241 | run: npm i 242 | - name: Release 243 | env: 244 | SLACK_WEBHOOK: ${{ secrets.SLACK_OBSERVABILITY_RELEASE_WEBHOOK }} 245 | run: npx semantic-release --debug 246 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IntelliJ 2 | .idea/ 3 | 4 | # VS Code 5 | .vscode 6 | 7 | # Environments 8 | .env 9 | .venv 10 | env/ 11 | venv/ 12 | ENV/ 13 | env.bak/ 14 | venv.bak/ 15 | 16 | # macOS 17 | .DS_Store 18 | 19 | # Python 20 | __pycache__/ 21 | *.py[cod] 22 | *$py.class 23 | .Python 24 | build/ 25 | dist/ 26 | .eggs/ 27 | *.egg-info 28 | 29 | # Docker 30 | docker/coverage 31 | !docker/netbox/env 32 | docker/oauth2/secrets/* 33 | !docker/oauth2/secrets/.gitkeep 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # NetBox Limited Use License 1.0 2 | 3 | ## Acceptance 4 | 5 | In order to get any license under these terms, you must agree to them as 6 | both strict obligations and conditions to all your licenses. 7 | 8 | ## Copyright License 9 | 10 | NetBox Labs grants you a copyright license to use and modify the software 11 | only as part of a NetBox installation obtained from NetBox Labs or a NetBox 12 | distributor authorized by NetBox Labs, and only for your own internal use. 13 | 14 | For clarity, this license grants no rights to distribute the software or 15 | make it available to others as part of a commercial offering. 16 | 17 | ## Patent License 18 | 19 | NetBox Labs grants you a patent license for the software that covers patent 20 | claims the licensor can license, or becomes able to license, that you would 21 | infringe by using the software, as allowed in the copyright license above. 22 | 23 | ## Patent Defense 24 | 25 | If you make any written claim that the software infringes or contributes to 26 | infringement of any patent, your patent license for the software granted 27 | under these terms ends immediately. If your company makes such a claim, 28 | your patent license ends immediately for work on behalf of your company. 29 | Competitive Restrictions 30 | 31 | This license does not grant you the right to use the software: 32 | 33 | - To provide a managed service or software products that includes, integrates 34 | with, or extends NetBox in a way that competes with any product or service 35 | of NetBox Labs. 36 | 37 | - To assist or enable a third party in offering a service or product that 38 | competes with any product or service of NetBox Labs. 39 | 40 | ## No Other Rights 41 | 42 | These terms do not allow you to sublicense or transfer any of your licenses 43 | to anyone else, or prevent NetBox Labs from granting licenses to anyone else. 44 | These terms do not imply any other licenses. 45 | 46 | ## Violations 47 | 48 | The first time you are notified in writing that you have violated any of 49 | these terms, or done anything with the software not covered by your licenses, 50 | your licenses can nonetheless continue if you come into full compliance with 51 | these terms, and take practical steps to correct past violations, within 30 52 | days of receiving notice. Otherwise, all your licenses end immediately. 53 | 54 | ## No Liability 55 | 56 | As far as the law allows, the software comes as is, without any warranty or 57 | condition, and NetBox Labs will not be liable to you for any damages arising 58 | out of these terms or the use or nature of the software, under any kind of 59 | legal claim. 60 | 61 | If this disclaimer is unenforceable under applicable law, this license is void. 62 | 63 | ## Definitions 64 | 65 | **NetBox Labs** is NetBox Labs, Inc. 66 | 67 | **NetBox** is the community edition of NetBox found at 68 | or any derivative thereof. 69 | 70 | The **software** is the software NetBox Labs makes available under these terms. 71 | 72 | **You** refers to the individual or entity agreeing to these terms. 73 | 74 | **Your company** is any legal entity, sole proprietorship, or other kind of 75 | organization that you work for, plus all organizations that have control over, 76 | are under the control of, or are under common control with that organization. 77 | 78 | **Control** means ownership of substantially all the assets of an entity, 79 | or the power to direct its management and policies by vote, contract, or 80 | otherwise. Control can be direct or indirect. 81 | 82 | **Your licenses** are all the licenses granted to you for the software under 83 | these terms. 84 | 85 | **Use** means anything you do with the software requiring one of your licenses. 86 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ifneq ($(shell docker compose version 2>/dev/null),) 2 | DOCKER_COMPOSE := docker compose 3 | else 4 | DOCKER_COMPOSE := docker-compose 5 | endif 6 | 7 | .PHONY: docker-compose-netbox-plugin-up 8 | docker-compose-netbox-plugin-up: 9 | @$(DOCKER_COMPOSE) -f docker/docker-compose.yaml up -d --build 10 | 11 | .PHONY: docker-compose-netbox-plugin-down 12 | docker-compose-netbox-plugin-down: 13 | @$(DOCKER_COMPOSE) -f docker/docker-compose.yaml down 14 | 15 | .PHONY: docker-compose-netbox-plugin-test 16 | docker-compose-netbox-plugin-test: 17 | -@$(DOCKER_COMPOSE) -f docker/docker-compose.yaml -f docker/docker-compose.test.yaml run -u root --rm netbox ./manage.py test $(TEST_FLAGS) --keepdb netbox_diode_plugin 18 | @$(MAKE) docker-compose-netbox-plugin-down 19 | 20 | .PHONY: docker-compose-netbox-plugin-test-cover 21 | docker-compose-netbox-plugin-test-cover: 22 | -@$(DOCKER_COMPOSE) -f docker/docker-compose.yaml -f docker/docker-compose.test.yaml run --rm -u root -e COVERAGE_FILE=/opt/netbox/netbox/coverage/.coverage netbox sh -c "coverage run --source=netbox_diode_plugin --omit=*/migrations/* ./manage.py test --keepdb netbox_diode_plugin && coverage xml -o /opt/netbox/netbox/coverage/report.xml && coverage report -m | tee /opt/netbox/netbox/coverage/report.txt" 23 | @$(MAKE) docker-compose-netbox-plugin-down 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Diode NetBox Plugin 2 | 3 | The Diode NetBox plugin is a [NetBox](https://netboxlabs.com/oss/netbox/) plugin. It is a required component of 4 | the [Diode](https://github.com/netboxlabs/diode) ingestion service. 5 | 6 | Diode is a NetBox ingestion service that greatly simplifies and enhances the process to add and update network data 7 | in NetBox, ensuring your network source of truth is always accurate and can be trusted to power your network automation 8 | pipelines. 9 | 10 | More information about Diode can be found 11 | at [https://netboxlabs.com/blog/introducing-diode-streamlining-data-ingestion-in-netbox/](https://netboxlabs.com/blog/introducing-diode-streamlining-data-ingestion-in-netbox/). 12 | 13 | ## Compatibility 14 | 15 | | NetBox Version | Plugin Version | 16 | |:--------------:|:--------------:| 17 | | >= 3.7.2 | 0.1.0 | 18 | | >= 4.1.0 | 0.4.0 | 19 | | >= 4.2.3 | 1.0.0 | 20 | 21 | ## Installation 22 | 23 | Source the NetBox Python virtual environment: 24 | 25 | ```shell 26 | cd /opt/netbox 27 | source venv/bin/activate 28 | ``` 29 | 30 | Install the plugin: 31 | 32 | ```bash 33 | pip install netboxlabs-diode-netbox-plugin 34 | ``` 35 | 36 | In your NetBox `configuration.py` file, add `netbox_diode_plugin` to the `PLUGINS` list. 37 | 38 | ```python 39 | PLUGINS = [ 40 | "netbox_diode_plugin", 41 | ] 42 | ``` 43 | 44 | Also in your `configuration.py` file, in order to customise the plugin settings, add `netbox_diode_plugin`to the 45 | `PLUGINS_CONFIG` dictionary, e.g.: 46 | 47 | ```python 48 | PLUGINS_CONFIG = { 49 | "netbox_diode_plugin": { 50 | # Diode gRPC target for communication with Diode server 51 | "diode_target_override": "grpc://localhost:8080/diode", 52 | 53 | # Username associated with changes applied via plugin 54 | "diode_username": "diode", 55 | 56 | # netbox-to-diode client_secret created during diode bootstrap. 57 | "netbox_to_diode_client_secret": "..." 58 | }, 59 | } 60 | ``` 61 | 62 | If you are running diode locally via the quickstart, the `netbox-to-diode` client_secret may be found in `/path/to/diode/oauth2/client/client-credentials.json`. eg: 63 | ``` 64 | echo $(jq -r '.[] | select(.client_id == "netbox-to-diode") | .client_secret' /path/to/diode/oauth2/client/client-credentials.json) 65 | ``` 66 | 67 | Note: Once you customise usernames with PLUGINS_CONFIG during first installation, you should not change or remove them 68 | later on. Doing so will cause the plugin to stop working properly. 69 | 70 | Restart NetBox services to load the plugin: 71 | 72 | ``` 73 | sudo systemctl restart netbox netbox-rq 74 | ``` 75 | 76 | See [NetBox Documentation](https://netboxlabs.com/docs/netbox/en/stable/plugins/#installing-plugins) for details. 77 | 78 | ## Configuration 79 | 80 | Source the NetBox Python virtual environment (if not already): 81 | 82 | ```shell 83 | cd /opt/netbox 84 | source venv/bin/activate 85 | ``` 86 | 87 | Run migrations to create all necessary resources: 88 | 89 | ```shell 90 | cd /opt/netbox/netbox 91 | ./manage.py migrate netbox_diode_plugin 92 | ``` 93 | 94 | ## Running Tests 95 | 96 | ```shell 97 | make docker-compose-netbox-plugin-test 98 | ``` 99 | 100 | ## License 101 | 102 | Distributed under the NetBox Limited Use License 1.0. See [LICENSE.md](./LICENSE.md) for more information. 103 | 104 | ## Required Notice 105 | 106 | Copyright NetBox Labs, Inc. 107 | 108 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | Please send any suspected vulnerability report to security@netboxlabs.com 6 | -------------------------------------------------------------------------------- /docker/Dockerfile-diode-netbox-plugin: -------------------------------------------------------------------------------- 1 | FROM netboxcommunity/netbox:v4.2.3-3.1.1 2 | 3 | COPY ./netbox/configuration/ /etc/netbox/config/ 4 | RUN chmod 755 /etc/netbox/config/* && \ 5 | chown unit:root /etc/netbox/config/* 6 | 7 | COPY ./netbox/local_settings.py /opt/netbox/netbox/netbox/local_settings.py 8 | RUN chmod 755 /opt/netbox/netbox/netbox/local_settings.py && \ 9 | chown unit:root /opt/netbox/netbox/netbox/local_settings.py 10 | 11 | COPY ./requirements-diode-netbox-plugin.txt /opt/netbox/ 12 | RUN /opt/netbox/venv/bin/pip install --no-warn-script-location -r /opt/netbox/requirements-diode-netbox-plugin.txt 13 | -------------------------------------------------------------------------------- /docker/docker-compose.test.yaml: -------------------------------------------------------------------------------- 1 | name: diode-netbox-plugin 2 | services: 3 | netbox: 4 | volumes: 5 | - ./netbox/plugins_test.py:/etc/netbox/config/plugins.py:z,ro 6 | -------------------------------------------------------------------------------- /docker/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | name: diode-netbox-plugin 2 | services: 3 | netbox: &netbox 4 | image: netboxcommunity/netbox:v4.2.3-3.1.1-diode-netbox-plugin 5 | build: 6 | context: . 7 | dockerfile: Dockerfile-diode-netbox-plugin 8 | pull: true 9 | depends_on: 10 | - netbox-postgres 11 | - netbox-redis 12 | - netbox-redis-cache 13 | env_file: netbox/env/netbox.env 14 | user: 'unit:root' 15 | healthcheck: 16 | start_period: 60s 17 | timeout: 3s 18 | interval: 15s 19 | test: "curl -f http://localhost:8080/netbox/api/ || exit 1" 20 | volumes: 21 | - ./netbox/docker-entrypoint.sh:/opt/netbox/docker-entrypoint.sh:z,ro 22 | - ./netbox/nginx-unit.json:/opt/netbox/nginx-unit.json:z,ro 23 | - ../netbox_diode_plugin:/opt/netbox/netbox/netbox_diode_plugin:z,rw 24 | - ./oauth2/secrets:/run/secrets:z,ro 25 | - ./netbox/launch-netbox.sh:/opt/netbox/launch-netbox.sh:z,ro 26 | - ./netbox/plugins_dev.py:/etc/netbox/config/plugins.py:z,ro 27 | - ./coverage:/opt/netbox/netbox/coverage:z,rw 28 | - netbox-media-files:/opt/netbox/netbox/media:rw 29 | - netbox-reports-files:/opt/netbox/netbox/reports:rw 30 | - netbox-scripts-files:/opt/netbox/netbox/scripts:rw 31 | extra_hosts: 32 | - "host.docker.internal:host-gateway" 33 | ports: 34 | - "8000:8080" 35 | 36 | netbox-worker: 37 | <<: *netbox 38 | depends_on: 39 | netbox: 40 | condition: service_healthy 41 | command: 42 | - /opt/netbox/venv/bin/python 43 | - /opt/netbox/netbox/manage.py 44 | - rqworker 45 | healthcheck: 46 | test: ps -aux | grep -v grep | grep -q rqworker || exit 1 47 | start_period: 20s 48 | timeout: 3s 49 | interval: 15s 50 | ports: [] 51 | 52 | # postgres 53 | netbox-postgres: 54 | image: docker.io/postgres:16-alpine 55 | env_file: netbox/env/postgres.env 56 | volumes: 57 | - netbox-postgres-data:/var/lib/postgresql/data 58 | 59 | # redis 60 | netbox-redis: 61 | image: docker.io/redis:7-alpine 62 | command: 63 | - sh 64 | - -c # this is to evaluate the $REDIS_PASSWORD from the env 65 | - redis-server --appendonly yes --requirepass $$REDIS_PASSWORD ## $$ because of docker-compose 66 | env_file: netbox/env/redis.env 67 | volumes: 68 | - netbox-redis-data:/data 69 | 70 | netbox-redis-cache: 71 | image: docker.io/redis:7-alpine 72 | command: 73 | - sh 74 | - -c # this is to evaluate the $REDIS_PASSWORD from the env 75 | - redis-server --requirepass $$REDIS_PASSWORD ## $$ because of docker-compose 76 | env_file: netbox/env/redis-cache.env 77 | volumes: 78 | - netbox-redis-cache-data:/data 79 | 80 | volumes: 81 | netbox-media-files: 82 | driver: local 83 | netbox-postgres-data: 84 | driver: local 85 | netbox-redis-cache-data: 86 | driver: local 87 | netbox-redis-data: 88 | driver: local 89 | netbox-reports-files: 90 | driver: local 91 | netbox-scripts-files: 92 | driver: local 93 | -------------------------------------------------------------------------------- /docker/netbox/configuration/extra.py: -------------------------------------------------------------------------------- 1 | #### 2 | ## This file contains extra configuration options that can't be configured 3 | ## directly through environment variables. 4 | #### 5 | 6 | ## Specify one or more name and email address tuples representing NetBox administrators. These people will be notified of 7 | ## application errors (assuming correct email settings are provided). 8 | # ADMINS = [ 9 | # # ['John Doe', 'jdoe@example.com'], 10 | # ] 11 | 12 | 13 | ## URL schemes that are allowed within links in NetBox 14 | # ALLOWED_URL_SCHEMES = ( 15 | # 'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp', 16 | # ) 17 | 18 | ## Enable installed plugins. Add the name of each plugin to the list. 19 | # from netbox.configuration.configuration import PLUGINS 20 | # PLUGINS.append('my_plugin') 21 | 22 | ## Plugins configuration settings. These settings are used by various plugins that the user may have installed. 23 | ## Each key in the dictionary is the name of an installed plugin and its value is a dictionary of settings. 24 | # from netbox.configuration.configuration import PLUGINS_CONFIG 25 | # PLUGINS_CONFIG['my_plugin'] = { 26 | # 'foo': 'bar', 27 | # 'buzz': 'bazz' 28 | # } 29 | 30 | 31 | ## Remote authentication support 32 | # REMOTE_AUTH_DEFAULT_PERMISSIONS = {} 33 | 34 | 35 | ## By default uploaded media is stored on the local filesystem. Using Django-storages is also supported. Provide the 36 | ## class path of the storage driver in STORAGE_BACKEND and any configuration options in STORAGE_CONFIG. For example: 37 | # STORAGE_BACKEND = 'storages.backends.s3boto3.S3Boto3Storage' 38 | # STORAGE_CONFIG = { 39 | # 'AWS_ACCESS_KEY_ID': 'Key ID', 40 | # 'AWS_SECRET_ACCESS_KEY': 'Secret', 41 | # 'AWS_STORAGE_BUCKET_NAME': 'netbox', 42 | # 'AWS_S3_REGION_NAME': 'eu-west-1', 43 | # } 44 | 45 | 46 | ## This file can contain arbitrary Python code, e.g.: 47 | # from datetime import datetime 48 | # now = datetime.now().strftime("%d/%m/%Y %H:%M:%S") 49 | # BANNER_TOP = f'This instance started on {now}.' 50 | -------------------------------------------------------------------------------- /docker/netbox/configuration/ldap/extra.py: -------------------------------------------------------------------------------- 1 | #### 2 | ## This file contains extra configuration options that can't be configured 3 | ## directly through environment variables. 4 | ## All vairables set here overwrite any existing found in ldap_config.py 5 | #### 6 | 7 | # # This Python script inherits all the imports from ldap_config.py 8 | # from django_auth_ldap.config import LDAPGroupQuery # Imported since not in ldap_config.py 9 | 10 | # # Sets a base requirement of membetship to netbox-user-ro, netbox-user-rw, or netbox-user-admin. 11 | # AUTH_LDAP_REQUIRE_GROUP = ( 12 | # LDAPGroupQuery("cn=netbox-user-ro,ou=groups,dc=example,dc=com") 13 | # | LDAPGroupQuery("cn=netbox-user-rw,ou=groups,dc=example,dc=com") 14 | # | LDAPGroupQuery("cn=netbox-user-admin,ou=groups,dc=example,dc=com") 15 | # ) 16 | 17 | # # Sets LDAP Flag groups variables with example. 18 | # AUTH_LDAP_USER_FLAGS_BY_GROUP = { 19 | # "is_staff": ( 20 | # LDAPGroupQuery("cn=netbox-user-ro,ou=groups,dc=example,dc=com") 21 | # | LDAPGroupQuery("cn=netbox-user-rw,ou=groups,dc=example,dc=com") 22 | # | LDAPGroupQuery("cn=netbox-user-admin,ou=groups,dc=example,dc=com") 23 | # ), 24 | # "is_superuser": "cn=netbox-user-admin,ou=groups,dc=example,dc=com", 25 | # } 26 | 27 | # # Sets LDAP Mirror groups variables with example groups 28 | # AUTH_LDAP_MIRROR_GROUPS = ["netbox-user-ro", "netbox-user-rw", "netbox-user-admin"] 29 | -------------------------------------------------------------------------------- /docker/netbox/configuration/ldap/ldap_config.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | from os import environ 3 | 4 | import ldap 5 | from django_auth_ldap.config import LDAPSearch 6 | 7 | 8 | # Read secret from file 9 | def _read_secret(secret_name, default=None): 10 | try: 11 | f = open('/run/secrets/' + secret_name, encoding='utf-8') 12 | except OSError: 13 | return default 14 | else: 15 | with f: 16 | return f.readline().strip() 17 | 18 | 19 | # Import and return the group type based on string name 20 | def _import_group_type(group_type_name): 21 | mod = import_module('django_auth_ldap.config') 22 | try: 23 | return getattr(mod, group_type_name)() 24 | except: 25 | return None 26 | 27 | 28 | # Server URI 29 | AUTH_LDAP_SERVER_URI = environ.get('AUTH_LDAP_SERVER_URI', '') 30 | 31 | # The following may be needed if you are binding to Active Directory. 32 | AUTH_LDAP_CONNECTION_OPTIONS = { 33 | ldap.OPT_REFERRALS: 0 34 | } 35 | 36 | AUTH_LDAP_BIND_AS_AUTHENTICATING_USER = environ.get('AUTH_LDAP_BIND_AS_AUTHENTICATING_USER', 'False').lower() == 'true' 37 | 38 | # Set the DN and password for the NetBox service account if needed. 39 | if not AUTH_LDAP_BIND_AS_AUTHENTICATING_USER: 40 | AUTH_LDAP_BIND_DN = environ.get('AUTH_LDAP_BIND_DN', '') 41 | AUTH_LDAP_BIND_PASSWORD = _read_secret('auth_ldap_bind_password', environ.get('AUTH_LDAP_BIND_PASSWORD', '')) 42 | 43 | # Set a string template that describes any user’s distinguished name based on the username. 44 | AUTH_LDAP_USER_DN_TEMPLATE = environ.get('AUTH_LDAP_USER_DN_TEMPLATE', None) 45 | 46 | # Enable STARTTLS for ldap authentication. 47 | AUTH_LDAP_START_TLS = environ.get('AUTH_LDAP_START_TLS', 'False').lower() == 'true' 48 | 49 | # Include this setting if you want to ignore certificate errors. This might be needed to accept a self-signed cert. 50 | # Note that this is a NetBox-specific setting which sets: 51 | # ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER) 52 | LDAP_IGNORE_CERT_ERRORS = environ.get('LDAP_IGNORE_CERT_ERRORS', 'False').lower() == 'true' 53 | 54 | # Include this setting if you want to validate the LDAP server certificates against a CA certificate directory on your server 55 | # Note that this is a NetBox-specific setting which sets: 56 | # ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, LDAP_CA_CERT_DIR) 57 | LDAP_CA_CERT_DIR = environ.get('LDAP_CA_CERT_DIR', None) 58 | 59 | # Include this setting if you want to validate the LDAP server certificates against your own CA. 60 | # Note that this is a NetBox-specific setting which sets: 61 | # ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, LDAP_CA_CERT_FILE) 62 | LDAP_CA_CERT_FILE = environ.get('LDAP_CA_CERT_FILE', None) 63 | 64 | AUTH_LDAP_USER_SEARCH_BASEDN = environ.get('AUTH_LDAP_USER_SEARCH_BASEDN', '') 65 | AUTH_LDAP_USER_SEARCH_ATTR = environ.get('AUTH_LDAP_USER_SEARCH_ATTR', 'sAMAccountName') 66 | AUTH_LDAP_USER_SEARCH_FILTER: str = environ.get( 67 | 'AUTH_LDAP_USER_SEARCH_FILTER', f'({AUTH_LDAP_USER_SEARCH_ATTR}=%(user)s)' 68 | ) 69 | 70 | AUTH_LDAP_USER_SEARCH = LDAPSearch( 71 | AUTH_LDAP_USER_SEARCH_BASEDN, ldap.SCOPE_SUBTREE, AUTH_LDAP_USER_SEARCH_FILTER 72 | ) 73 | 74 | # This search ought to return all groups to which the user belongs. django_auth_ldap uses this to determine group 75 | # heirarchy. 76 | 77 | AUTH_LDAP_GROUP_SEARCH_BASEDN = environ.get('AUTH_LDAP_GROUP_SEARCH_BASEDN', '') 78 | AUTH_LDAP_GROUP_SEARCH_CLASS = environ.get('AUTH_LDAP_GROUP_SEARCH_CLASS', 'group') 79 | 80 | AUTH_LDAP_GROUP_SEARCH_FILTER: str = environ.get( 81 | 'AUTH_LDAP_GROUP_SEARCH_FILTER', f'(objectclass={AUTH_LDAP_GROUP_SEARCH_CLASS})' 82 | ) 83 | AUTH_LDAP_GROUP_SEARCH = LDAPSearch( 84 | AUTH_LDAP_GROUP_SEARCH_BASEDN, ldap.SCOPE_SUBTREE, AUTH_LDAP_GROUP_SEARCH_FILTER 85 | ) 86 | AUTH_LDAP_GROUP_TYPE = _import_group_type(environ.get('AUTH_LDAP_GROUP_TYPE', 'GroupOfNamesType')) 87 | 88 | # Define a group required to login. 89 | AUTH_LDAP_REQUIRE_GROUP = environ.get('AUTH_LDAP_REQUIRE_GROUP_DN') 90 | 91 | # Define special user types using groups. Exercise great caution when assigning superuser status. 92 | AUTH_LDAP_USER_FLAGS_BY_GROUP = {} 93 | 94 | if AUTH_LDAP_REQUIRE_GROUP is not None: 95 | AUTH_LDAP_USER_FLAGS_BY_GROUP = { 96 | "is_active": environ.get('AUTH_LDAP_REQUIRE_GROUP_DN', ''), 97 | "is_staff": environ.get('AUTH_LDAP_IS_ADMIN_DN', ''), 98 | "is_superuser": environ.get('AUTH_LDAP_IS_SUPERUSER_DN', '') 99 | } 100 | 101 | # For more granular permissions, we can map LDAP groups to Django groups. 102 | AUTH_LDAP_FIND_GROUP_PERMS = environ.get('AUTH_LDAP_FIND_GROUP_PERMS', 'True').lower() == 'true' 103 | AUTH_LDAP_MIRROR_GROUPS = environ.get('AUTH_LDAP_MIRROR_GROUPS', '').lower() == 'true' 104 | 105 | # Cache groups for one hour to reduce LDAP traffic 106 | AUTH_LDAP_CACHE_TIMEOUT = int(environ.get('AUTH_LDAP_CACHE_TIMEOUT', 3600)) 107 | 108 | # Populate the Django user from the LDAP directory. 109 | AUTH_LDAP_USER_ATTR_MAP = { 110 | "first_name": environ.get('AUTH_LDAP_ATTR_FIRSTNAME', 'givenName'), 111 | "last_name": environ.get('AUTH_LDAP_ATTR_LASTNAME', 'sn'), 112 | "email": environ.get('AUTH_LDAP_ATTR_MAIL', 'mail') 113 | } 114 | -------------------------------------------------------------------------------- /docker/netbox/configuration/logging.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | 3 | LOGGING = { 4 | 'version': 1, 5 | 'disable_existing_loggers': False, 6 | 'handlers': { 7 | 'console': { 8 | 'class': 'logging.StreamHandler', 9 | }, 10 | }, 11 | 'loggers': { 12 | '': { # root logger 13 | 'handlers': ['console'], 14 | 'level': 'DEBUG' if environ.get('DEBUG', 'false').lower() == 'true' else 'INFO', 15 | }, 16 | }, 17 | } 18 | # # Remove first comment(#) on each line to implement this working logging example. 19 | # # Add LOGLEVEL environment variable to netbox if you use this example & want a different log level. 20 | # from os import environ 21 | 22 | # # Set LOGLEVEL in netbox.env or docker-compose.overide.yml to override a logging level of INFO. 23 | # LOGLEVEL = environ.get('LOGLEVEL', 'INFO') 24 | 25 | # LOGGING = { 26 | 27 | # 'version': 1, 28 | # 'disable_existing_loggers': False, 29 | # 'formatters': { 30 | # 'verbose': { 31 | # 'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}', 32 | # 'style': '{', 33 | # }, 34 | # 'simple': { 35 | # 'format': '{levelname} {message}', 36 | # 'style': '{', 37 | # }, 38 | # }, 39 | # 'filters': { 40 | # 'require_debug_false': { 41 | # '()': 'django.utils.log.RequireDebugFalse', 42 | # }, 43 | # }, 44 | # 'handlers': { 45 | # 'console': { 46 | # 'level': LOGLEVEL, 47 | # 'filters': ['require_debug_false'], 48 | # 'class': 'logging.StreamHandler', 49 | # 'formatter': 'simple' 50 | # }, 51 | # 'mail_admins': { 52 | # 'level': 'ERROR', 53 | # 'class': 'django.utils.log.AdminEmailHandler', 54 | # 'filters': ['require_debug_false'] 55 | # } 56 | # }, 57 | # 'loggers': { 58 | # 'django': { 59 | # 'handlers': ['console'], 60 | # 'propagate': True, 61 | # }, 62 | # 'django.request': { 63 | # 'handlers': ['mail_admins'], 64 | # 'level': 'ERROR', 65 | # 'propagate': False, 66 | # }, 67 | # 'django_auth_ldap': { 68 | # 'handlers': ['console',], 69 | # 'level': LOGLEVEL, 70 | # } 71 | # } 72 | # } 73 | -------------------------------------------------------------------------------- /docker/netbox/configuration/plugins.py: -------------------------------------------------------------------------------- 1 | # Add your plugins and plugin settings here. 2 | # Of course uncomment this file out. 3 | 4 | # To learn how to build images with your required plugins 5 | # See https://github.com/netbox-community/netbox-docker/wiki/Using-Netbox-Plugins 6 | 7 | PLUGINS = [ 8 | "netbox_diode_plugin", 9 | "netbox_branching", 10 | ] 11 | 12 | # PLUGINS_CONFIG = { 13 | # "netbox_diode_plugin": { 14 | # # Auto-provision users for Diode plugin 15 | # "auto_provision_users": True, 16 | # 17 | # # Diode gRPC target for communication with Diode server 18 | # "diode_target_override": "grpc://localhost:8080/diode", 19 | # 20 | # # User allowed for Diode to NetBox communication 21 | # "diode_to_netbox_username": "diode-to-netbox", 22 | # 23 | # # User allowed for NetBox to Diode communication 24 | # "netbox_to_diode_username": "netbox-to-diode", 25 | # 26 | # # User allowed for data ingestion 27 | # "diode_username": "diode-ingestion", 28 | # }, 29 | # } 30 | -------------------------------------------------------------------------------- /docker/netbox/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Runs on every start of the NetBox Docker container 3 | 4 | # Stop when an error occures 5 | set -e 6 | 7 | # Allows NetBox to be run as non-root users 8 | umask 002 9 | 10 | # Load correct Python3 env 11 | # shellcheck disable=SC1091 12 | source /opt/netbox/venv/bin/activate 13 | 14 | # Try to connect to the DB 15 | DB_WAIT_TIMEOUT=${DB_WAIT_TIMEOUT-3} 16 | MAX_DB_WAIT_TIME=${MAX_DB_WAIT_TIME-30} 17 | CUR_DB_WAIT_TIME=0 18 | while [ "${CUR_DB_WAIT_TIME}" -lt "${MAX_DB_WAIT_TIME}" ]; do 19 | # Read and truncate connection error tracebacks to last line by default 20 | exec {psfd}< <(./manage.py showmigrations 2>&1) 21 | read -rd '' DB_ERR <&$psfd || : 22 | exec {psfd}<&- 23 | wait $! && break 24 | if [ -n "$DB_WAIT_DEBUG" ]; then 25 | echo "$DB_ERR" 26 | else 27 | readarray -tn 0 DB_ERR_LINES <<<"$DB_ERR" 28 | echo "${DB_ERR_LINES[@]: -1}" 29 | echo "[ Use DB_WAIT_DEBUG=1 in netbox.env to print full traceback for errors here ]" 30 | fi 31 | echo "⏳ Waiting on DB... (${CUR_DB_WAIT_TIME}s / ${MAX_DB_WAIT_TIME}s)" 32 | sleep "${DB_WAIT_TIMEOUT}" 33 | CUR_DB_WAIT_TIME=$((CUR_DB_WAIT_TIME + DB_WAIT_TIMEOUT)) 34 | done 35 | if [ "${CUR_DB_WAIT_TIME}" -ge "${MAX_DB_WAIT_TIME}" ]; then 36 | echo "❌ Waited ${MAX_DB_WAIT_TIME}s or more for the DB to become ready." 37 | exit 1 38 | fi 39 | # Check if update is needed 40 | if ! ./manage.py migrate --check >/dev/null 2>&1; then 41 | echo "⚙️ Applying database migrations" 42 | ./manage.py migrate --no-input 43 | echo "⚙️ Running trace_paths" 44 | ./manage.py trace_paths --no-input 45 | echo "⚙️ Removing stale content types" 46 | ./manage.py remove_stale_contenttypes --no-input 47 | echo "⚙️ Removing expired user sessions" 48 | ./manage.py clearsessions 49 | echo "⚙️ Building search index (lazy)" 50 | ./manage.py reindex --lazy 51 | fi 52 | 53 | # Create Superuser if required 54 | if [ "$SKIP_SUPERUSER" == "true" ]; then 55 | echo "↩️ Skip creating the superuser" 56 | else 57 | if [ -z ${SUPERUSER_NAME+x} ]; then 58 | SUPERUSER_NAME='admin' 59 | fi 60 | if [ -z ${SUPERUSER_EMAIL+x} ]; then 61 | SUPERUSER_EMAIL='admin@example.com' 62 | fi 63 | if [ -f "/run/secrets/superuser_password" ]; then 64 | SUPERUSER_PASSWORD="$( ChangeSetResult: 22 | """Apply a change set.""" 23 | _validate_change_set(change_set) 24 | 25 | created = {} 26 | for change in change_set.changes: 27 | change_type = change.change_type 28 | object_type = change.object_type 29 | 30 | if change_type == ChangeType.NOOP.value: 31 | continue 32 | 33 | try: 34 | model_class = get_object_type_model(object_type) 35 | data = _pre_apply(model_class, change, created) 36 | _apply_change(data, model_class, change, created, request) 37 | except ValidationError as e: 38 | raise error_from_validation_error(e, object_type) 39 | except ObjectDoesNotExist: 40 | raise _err(f"{object_type} with id {change.object_id} does not exist", object_type, "object_id") 41 | except TypeError as e: 42 | # this indicates a problem in model validation (should raise ValidationError) 43 | # but raised non-validation error (TypeError) -- we don't know which field trigged it. 44 | import traceback 45 | traceback.print_exc() 46 | logger.error(f"validation raised TypeError error on unspecified field of {object_type}: {data}: {e}") 47 | logger.error(traceback.format_exc()) 48 | raise _err("invalid data type for field (TypeError)", object_type, "__all__") 49 | except IntegrityError as e: 50 | logger.error(f"Integrity error {object_type}: {e} {data}") 51 | raise _err(f"created a conflict with an existing {object_type}", object_type, "__all__") 52 | 53 | return ChangeSetResult( 54 | id=change_set.id, 55 | ) 56 | 57 | def _apply_change(data: dict, model_class: models.Model, change: Change, created: dict, request): 58 | serializer_class = get_serializer_for_model(model_class) 59 | change_type = change.change_type 60 | if change_type == ChangeType.CREATE.value: 61 | serializer = serializer_class(data=data, context={"request": request}) 62 | try: 63 | serializer.is_valid(raise_exception=True) 64 | instance = serializer.save() 65 | except ValidationError as e: 66 | instance = find_existing_object(data, change.object_type) 67 | if not instance: 68 | raise e 69 | created[change.ref_id] = instance 70 | 71 | elif change_type == ChangeType.UPDATE.value: 72 | if object_id := change.object_id: 73 | instance = model_class.objects.get(id=object_id) 74 | serializer = serializer_class(instance, data=data, partial=True, context={"request": request}) 75 | serializer.is_valid(raise_exception=True) 76 | serializer.save() 77 | # create and update in a same change set 78 | elif change.ref_id and (instance := created[change.ref_id]): 79 | serializer = serializer_class(instance, data=data, partial=True, context={"request": request}) 80 | serializer.is_valid(raise_exception=True) 81 | serializer.save() 82 | 83 | def _set_path(data, path, value): 84 | path = path.split(".") 85 | key = path.pop(0) 86 | while len(path) > 0: 87 | data = data[key] 88 | key = path.pop(0) 89 | data[key] = value 90 | 91 | def _get_path(data, path): 92 | path = path.split(".") 93 | v = data 94 | for p in path: 95 | v = v[p] 96 | return v 97 | 98 | def _pre_apply(model_class: models.Model, change: Change, created: dict): 99 | data = change.data.copy() 100 | 101 | # resolve foreign key references to new objects 102 | for ref_field in change.new_refs: 103 | v = _get_path(data, ref_field) 104 | if isinstance(v, (list, tuple)): 105 | ref_list = [] 106 | for ref in v: 107 | if isinstance(ref, str): 108 | ref_list.append(created[ref].pk) 109 | elif isinstance(ref, int): 110 | ref_list.append(ref) 111 | _set_path(data, ref_field, ref_list) 112 | else: 113 | _set_path(data, ref_field, created[v].pk) 114 | 115 | # ignore? fields that are not in the data model (error?) 116 | allowed_fields = legal_fields(model_class) 117 | for key in list(data.keys()): 118 | if key not in allowed_fields: 119 | if key != "id": 120 | logger.warning(f"Field {key} is not in the diode data model, ignoring.") 121 | data.pop(key) 122 | 123 | return data 124 | 125 | def _validate_change_set(change_set: ChangeSet): 126 | if not change_set.id: 127 | raise _err("Change set ID is required", "changeset","id") 128 | if not change_set.changes: 129 | raise _err("Changes are required", "changeset", "changes") 130 | 131 | for change in change_set.changes: 132 | if change.object_id is None and change.ref_id is None: 133 | raise _err("Object ID or Ref ID must be provided", change.object_type, NON_FIELD_ERRORS) 134 | if change.change_type not in [ct.value for ct in ChangeType]: 135 | raise _err(f"Unsupported change type '{change.change_type}'", change.object_type, "change_type") 136 | 137 | def _err(message, object_name, field): 138 | if not object_name: 139 | object_name = "__all__" 140 | return ChangeSetException(message, errors={object_name: {field: [message]}}) 141 | 142 | -------------------------------------------------------------------------------- /netbox_diode_plugin/api/authentication.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2025 NetBox Labs, Inc. 3 | """Diode NetBox Plugin - API Authentication.""" 4 | 5 | import hashlib 6 | import logging 7 | from types import SimpleNamespace 8 | 9 | import requests 10 | from django.core.cache import cache 11 | from rest_framework.authentication import BaseAuthentication 12 | from rest_framework.exceptions import AuthenticationFailed 13 | 14 | from netbox_diode_plugin.plugin_config import ( 15 | get_diode_auth_introspect_url, 16 | get_diode_user, 17 | ) 18 | 19 | logger = logging.getLogger("netbox.diode_data") 20 | 21 | 22 | class DiodeOAuth2Authentication(BaseAuthentication): 23 | """Diode OAuth2 Client Credentials Authentication.""" 24 | 25 | def authenticate(self, request): 26 | """Authenticate the request and return the user info.""" 27 | auth_header = request.headers.get("Authorization", "") 28 | if not auth_header.startswith("Bearer "): 29 | return None 30 | 31 | token = auth_header[7:].strip() 32 | 33 | diode_user = self._introspect_token(token) 34 | if not diode_user: 35 | raise AuthenticationFailed("Invalid token") 36 | 37 | request.user = diode_user.user 38 | request.token_scopes = diode_user.token_scopes 39 | request.token_data = diode_user.token_data 40 | 41 | return (diode_user.user, None) 42 | 43 | def _introspect_token(self, token: str): 44 | """Introspect the token and return the client info.""" 45 | hash_token = hashlib.sha256(token.encode()).hexdigest() 46 | cache_key = f"diode:oauth2:introspect:{hash_token}" 47 | cached_user = cache.get(cache_key) 48 | if cached_user: 49 | return cached_user 50 | 51 | introspect_url = get_diode_auth_introspect_url() 52 | 53 | if not introspect_url: 54 | logger.error("Diode Auth introspect URL is not configured") 55 | return None 56 | 57 | try: 58 | response = requests.post( 59 | introspect_url, headers={"Authorization": f"Bearer {token}"}, timeout=5 60 | ) 61 | response.raise_for_status() 62 | data = response.json() 63 | except Exception as e: 64 | logger.error(f"Diode Auth token introspection failed: {e}") 65 | return None 66 | 67 | if data.get("active"): 68 | diode_user = SimpleNamespace( 69 | user=get_diode_user(), 70 | token_scopes=data.get("scope", "").split(), 71 | token_data=data, 72 | ) 73 | 74 | expires_in = ( 75 | data.get("exp") - data.get("iat") 76 | if "exp" in data and "iat" in data 77 | else 300 78 | ) 79 | cache.set(cache_key, diode_user, timeout=expires_in) 80 | return diode_user 81 | 82 | return None 83 | -------------------------------------------------------------------------------- /netbox_diode_plugin/api/common.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2025 NetBox Labs Inc 3 | """Diode NetBox Plugin - API - Common types and utilities.""" 4 | 5 | import datetime 6 | import decimal 7 | import logging 8 | import uuid 9 | from collections import defaultdict 10 | from dataclasses import dataclass, field 11 | from enum import Enum 12 | 13 | import netaddr 14 | from django.apps import apps 15 | from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation 16 | from django.contrib.contenttypes.models import ContentType 17 | from django.core.exceptions import ValidationError 18 | from django.db import models 19 | from django.db.backends.postgresql.psycopg_any import NumericRange 20 | from extras.models import CustomField 21 | from netaddr.eui import EUI 22 | from rest_framework import status 23 | from zoneinfo import ZoneInfo 24 | 25 | logger = logging.getLogger("netbox.diode_data") 26 | 27 | NON_FIELD_ERRORS = "__all__" 28 | 29 | @dataclass 30 | class UnresolvedReference: 31 | """unresolved reference to an object.""" 32 | 33 | object_type: str 34 | uuid: str 35 | 36 | def __str__(self): 37 | """String representation of the unresolved reference.""" 38 | return f"new_object:{self.object_type}:{self.uuid}" 39 | 40 | def __eq__(self, other): 41 | """Equality operator.""" 42 | if not isinstance(other, UnresolvedReference): 43 | return False 44 | return self.object_type == other.object_type and self.uuid == other.uuid 45 | 46 | def __hash__(self): 47 | """Hash function.""" 48 | return hash((self.object_type, self.uuid)) 49 | 50 | def __lt__(self, other): 51 | """Less than operator.""" 52 | if not isinstance(other, UnresolvedReference): 53 | return False 54 | return self.object_type < other.object_type or (self.object_type == other.object_type and self.uuid < other.uuid) 55 | 56 | 57 | class ChangeType(Enum): 58 | """Change type enum.""" 59 | 60 | CREATE = "create" 61 | UPDATE = "update" 62 | NOOP = "noop" 63 | 64 | 65 | @dataclass 66 | class Change: 67 | """A change to a model instance.""" 68 | 69 | change_type: ChangeType 70 | object_type: str 71 | object_id: int | None = field(default=None) 72 | object_primary_value: str | None = field(default=None) 73 | ref_id: str | None = field(default=None) 74 | id: str = field(default_factory=lambda: str(uuid.uuid4())) 75 | before: dict | None = field(default=None) 76 | data: dict | None = field(default=None) 77 | new_refs: list[str] = field(default_factory=list) 78 | 79 | def to_dict(self) -> dict: 80 | """Convert the change to a dictionary.""" 81 | return { 82 | "id": self.id, 83 | "change_type": self.change_type.value, 84 | "object_type": self.object_type, 85 | "object_id": self.object_id, 86 | "ref_id": self.ref_id, 87 | "object_primary_value": self.object_primary_value, 88 | "before": self.before, 89 | "data": self.data, 90 | "new_refs": self.new_refs, 91 | } 92 | 93 | 94 | @dataclass 95 | class ChangeSet: 96 | """A set of changes to a model instance.""" 97 | 98 | id: str = field(default_factory=lambda: str(uuid.uuid4())) 99 | changes: list[Change] = field(default_factory=list) 100 | branch: dict[str, str] | None = field(default=None) # {"id": str, "name": str} 101 | 102 | def to_dict(self) -> dict: 103 | """Convert the change set to a dictionary.""" 104 | return { 105 | "id": self.id, 106 | "changes": [change.to_dict() for change in self.changes], 107 | "branch": self.branch, 108 | } 109 | 110 | def validate(self) -> dict[str, list[str]]: 111 | """Validate basics of the change set data.""" 112 | errors = defaultdict(dict) 113 | 114 | for change in self.changes: 115 | model = apps.get_model(change.object_type) 116 | 117 | change_data = change.data.copy() 118 | if change.before: 119 | change_data.update(change.before) 120 | 121 | excluded_relation_fields, rel_errors = self._validate_relations(change_data, model) 122 | if rel_errors: 123 | errors[change.object_type] = rel_errors 124 | 125 | try: 126 | custom_fields = change_data.pop('custom_fields', None) 127 | if custom_fields: 128 | self._validate_custom_fields(custom_fields, model) 129 | 130 | instance = model(**change_data) 131 | instance.clean_fields(exclude=excluded_relation_fields) 132 | except ValidationError as e: 133 | errors[change.object_type].update(_error_dict(e)) 134 | 135 | return errors or None 136 | 137 | def _validate_custom_fields(self, data: dict, model: models.Model) -> None: 138 | custom_fields = { 139 | cf.name: cf for cf in CustomField.objects.get_for_model(model) 140 | } 141 | 142 | unknown_errors = [] 143 | for field_name, value in data.items(): 144 | if field_name not in custom_fields: 145 | unknown_errors.append(f"Unknown field name '{field_name}' in custom field data.") 146 | continue 147 | if unknown_errors: 148 | raise ValidationError({ 149 | "custom_fields": unknown_errors 150 | }) 151 | 152 | req_errors = [] 153 | for field_name, cf in custom_fields.items(): 154 | if cf.required and field_name not in data: 155 | req_errors.append(f"Custom field '{field_name}' is required.") 156 | if req_errors: 157 | raise ValidationError({ 158 | "custom_fields": req_errors 159 | }) 160 | 161 | def _validate_relations(self, change_data: dict, model: models.Model) -> tuple[list[str], dict]: 162 | # check that there is some value for every required 163 | # reference field, but don't validate the actual reference. 164 | # the fields are removed from the change_data so that other 165 | # fields can be validated by instantiating the model. 166 | excluded_relation_fields = [] 167 | rel_errors = defaultdict(list) 168 | for f in model._meta.get_fields(): 169 | if isinstance(f, (GenericRelation, GenericForeignKey)): 170 | excluded_relation_fields.append(f.name) 171 | continue 172 | if not f.is_relation: 173 | continue 174 | field_name = f.name 175 | excluded_relation_fields.append(field_name) 176 | 177 | if hasattr(f, "related_model") and f.related_model == ContentType: 178 | change_data.pop(field_name, None) 179 | base_field = field_name[:-5] 180 | excluded_relation_fields.append(base_field + "_id") 181 | value = change_data.pop(base_field + "_id", None) 182 | else: 183 | value = change_data.pop(field_name, None) 184 | 185 | if not f.null and not f.blank and not f.many_to_many: 186 | # this field is a required relation... 187 | if value is None: 188 | rel_errors[f.name].append(f"Field {f.name} is required") 189 | return excluded_relation_fields, rel_errors 190 | 191 | 192 | @dataclass 193 | class ChangeSetResult: 194 | """A result of applying a change set.""" 195 | 196 | id: str | None = field(default_factory=lambda: str(uuid.uuid4())) 197 | change_set: ChangeSet | None = field(default=None) 198 | errors: dict | None = field(default=None) 199 | 200 | def to_dict(self) -> dict: 201 | """Convert the result to a dictionary.""" 202 | result = { 203 | "id": self.id, 204 | "errors": self.errors, 205 | } 206 | 207 | if self.change_set: 208 | result["change_set"] = self.change_set.to_dict() 209 | 210 | return result 211 | 212 | def get_status_code(self) -> int: 213 | """Get the status code for the result.""" 214 | return status.HTTP_200_OK if not self.errors else status.HTTP_400_BAD_REQUEST 215 | 216 | 217 | class ChangeSetException(Exception): 218 | """ChangeSetException is raised when an error occurs while generating or applying a change set.""" 219 | 220 | def __init__(self, message, errors=None): 221 | """Initialize the exception.""" 222 | super().__init__(message) 223 | self.message = message 224 | self.errors = errors or {} 225 | 226 | def __str__(self): 227 | """Return the string representation of the exception.""" 228 | if self.errors: 229 | return f"{self.message}: {self.errors}" 230 | return self.message 231 | 232 | def _error_dict(e: ValidationError) -> dict: 233 | """Convert a ValidationError to a dictionary.""" 234 | if hasattr(e, "error_dict"): 235 | return e.error_dict 236 | return { 237 | "__all__": e.error_list 238 | } 239 | 240 | @dataclass 241 | class AutoSlug: 242 | """A class that marks an auto-generated slug.""" 243 | 244 | field_name: str 245 | value: str 246 | 247 | 248 | def error_from_validation_error(e, object_name): 249 | """Convert a from DRF ValidationError to a ChangeSetException.""" 250 | errors = {} 251 | if e.detail: 252 | if isinstance(e.detail, dict): 253 | errors[object_name] = e.detail 254 | elif isinstance(e.detail, (list, tuple)): 255 | errors[object_name] = { 256 | NON_FIELD_ERRORS: e.detail 257 | } 258 | else: 259 | errors[object_name] = { 260 | NON_FIELD_ERRORS: [e.detail] 261 | } 262 | return ChangeSetException("validation error", errors=errors) 263 | 264 | def harmonize_formats(data): 265 | """Puts all data in a format that can be serialized and compared.""" 266 | match data: 267 | case None: 268 | return None 269 | case str() | int() | float() | bool() | decimal.Decimal() | UnresolvedReference(): 270 | return data 271 | case dict(): 272 | return {k: harmonize_formats(v) if not k.startswith("_") else v for k, v in data.items()} 273 | case list() | tuple(): 274 | return [harmonize_formats(v) for v in data] 275 | case datetime.datetime(): 276 | return data.strftime("%Y-%m-%dT%H:%M:%SZ") 277 | case datetime.date(): 278 | return data.strftime("%Y-%m-%d") 279 | case NumericRange(): 280 | return (data.lower, data.upper-1) 281 | case netaddr.IPNetwork() | EUI() | ZoneInfo(): 282 | return str(data) 283 | case _: 284 | logger.warning(f"Unknown type in harmonize_formats: {type(data)}") 285 | return data 286 | 287 | def sort_ints_first(data): 288 | """Sort a mixed list of ints and other types, putting ints first.""" 289 | return sorted(data, key=lambda x: (not isinstance(x, int), x)) 290 | -------------------------------------------------------------------------------- /netbox_diode_plugin/api/differ.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2025 NetBox Labs Inc 3 | """Diode NetBox Plugin - API - Differ.""" 4 | 5 | import copy 6 | import datetime 7 | import logging 8 | 9 | from django.contrib.contenttypes.models import ContentType 10 | from rest_framework import serializers 11 | from utilities.data import shallow_compare_dict 12 | 13 | from .common import ( 14 | NON_FIELD_ERRORS, 15 | Change, 16 | ChangeSet, 17 | ChangeSetException, 18 | ChangeSetResult, 19 | ChangeType, 20 | error_from_validation_error, 21 | harmonize_formats, 22 | sort_ints_first, 23 | ) 24 | from .plugin_utils import get_primary_value, legal_fields 25 | from .supported_models import extract_supported_models 26 | from .transformer import cleanup_unresolved_references, set_custom_field_defaults, transform_proto_json 27 | 28 | logger = logging.getLogger(__name__) 29 | 30 | SUPPORTED_MODELS = extract_supported_models() 31 | 32 | 33 | def prechange_data_from_instance(instance) -> dict: # noqa: C901 34 | """Convert model instance data to a dictionary format for comparison.""" 35 | prechange_data = {} 36 | 37 | if instance is None: 38 | return prechange_data 39 | 40 | model_class = instance.__class__ 41 | object_type = f"{model_class._meta.app_label}.{model_class._meta.model_name}" 42 | 43 | model = SUPPORTED_MODELS.get(object_type) 44 | if not model: 45 | raise serializers.ValidationError({ 46 | NON_FIELD_ERRORS: [f"Model {model_class.__name__} is not supported"] 47 | }) 48 | 49 | fields = model.get("fields", {}) 50 | if not fields: 51 | raise serializers.ValidationError({ 52 | NON_FIELD_ERRORS: [f"Model {model_class.__name__} has no fields"] 53 | }) 54 | 55 | diode_fields = legal_fields(model_class) 56 | 57 | for field_name, field_info in fields.items(): 58 | # permit only diode fields and the primary key 59 | if field_name not in diode_fields and field_name != "id": 60 | continue 61 | 62 | if not hasattr(instance, field_name): 63 | continue 64 | 65 | value = getattr(instance, field_name) 66 | if hasattr(value, "all"): # Handle many-to-many and many-to-one relationships 67 | # For any relationship that has an 'all' method, get all related objects' primary keys 68 | prechange_data[field_name] = ( 69 | sorted([item.pk for item in value.all()] if value is not None else []) 70 | ) 71 | elif hasattr( 72 | value, "pk" 73 | ): # Handle regular related fields (ForeignKey, OneToOne) 74 | # Handle ContentType fields 75 | if isinstance(value, ContentType): 76 | prechange_data[field_name] = f"{value.app_label}.{value.model}" 77 | else: 78 | # For regular related fields, get the primary key 79 | prechange_data[field_name] = value.pk if value is not None else None 80 | else: 81 | prechange_data[field_name] = value 82 | 83 | if hasattr(instance, "get_custom_fields"): 84 | custom_field_values = instance.get_custom_fields() 85 | cfmap = {} 86 | for cf, value in custom_field_values.items(): 87 | if isinstance(value, (datetime.datetime, datetime.date)): 88 | cfmap[cf.name] = value 89 | else: 90 | cfmap[cf.name] = cf.serialize(value) 91 | prechange_data["custom_fields"] = cfmap 92 | prechange_data = harmonize_formats(prechange_data) 93 | 94 | return prechange_data 95 | 96 | 97 | def clean_diff_data(data: dict, exclude_empty_values: bool = True) -> dict: 98 | """Clean diff data by removing null values.""" 99 | result = {} 100 | for k, v in data.items(): 101 | if exclude_empty_values: 102 | if v is None: 103 | continue 104 | if isinstance(v, list) and len(v) == 0: 105 | continue 106 | if isinstance(v, dict): 107 | if len(v) == 0: 108 | continue 109 | v = clean_diff_data(v, exclude_empty_values) 110 | if isinstance(v, str) and v == "": 111 | continue 112 | result[k] = v 113 | return result 114 | 115 | 116 | def diff_to_change( 117 | object_type: str, 118 | prechange_data: dict, 119 | postchange_data: dict, 120 | changed_attrs: list[str], 121 | unresolved_references: list[str], 122 | ) -> Change: 123 | """Convert a diff to a change.""" 124 | change_type = ChangeType.UPDATE if len(prechange_data) > 0 else ChangeType.CREATE 125 | if change_type == ChangeType.UPDATE and not len(changed_attrs) > 0: 126 | change_type = ChangeType.NOOP 127 | primary_value = str(get_primary_value(prechange_data | postchange_data, object_type)) 128 | if primary_value is None: 129 | primary_value = "(unnamed)" 130 | 131 | prior_id = prechange_data.get("id") 132 | ref_id = None 133 | if prior_id is None: 134 | ref_id = postchange_data.pop("id", None) 135 | 136 | change = Change( 137 | change_type=change_type, 138 | before=_tidy(prechange_data), 139 | data={}, 140 | object_type=object_type, 141 | object_id=prior_id if isinstance(prior_id, int) else None, 142 | ref_id=ref_id, 143 | object_primary_value=primary_value, 144 | new_refs=unresolved_references, 145 | ) 146 | 147 | if change_type != ChangeType.NOOP: 148 | change.data = _tidy(postchange_data) 149 | 150 | return change 151 | 152 | def _tidy(data: dict) -> dict: 153 | return sort_dict_recursively(clean_diff_data(data)) 154 | 155 | def sort_dict_recursively(d): 156 | """Recursively sorts a dictionary by keys.""" 157 | if isinstance(d, dict): 158 | return {k: sort_dict_recursively(v) for k, v in sorted(d.items())} 159 | if isinstance(d, list): 160 | return [sort_dict_recursively(item) for item in d] 161 | return d 162 | 163 | def generate_changeset(entity: dict, object_type: str) -> ChangeSetResult: 164 | """Generate a changeset for an entity.""" 165 | try: 166 | return _generate_changeset(entity, object_type) 167 | except ChangeSetException: 168 | raise 169 | except serializers.ValidationError as e: 170 | raise error_from_validation_error(e, object_type) 171 | except Exception as e: 172 | logger.error(f"Unexpected error generating changeset: {e}") 173 | raise 174 | 175 | def _generate_changeset(entity: dict, object_type: str) -> ChangeSetResult: 176 | """Generate a changeset for an entity.""" 177 | change_set = ChangeSet() 178 | 179 | entities = transform_proto_json(entity, object_type, SUPPORTED_MODELS) 180 | by_uuid = {x['_uuid']: x for x in entities} 181 | for entity in entities: 182 | prechange_data = {} 183 | changed_attrs = [] 184 | new_refs = cleanup_unresolved_references(entity) 185 | object_type = entity.pop("_object_type") 186 | _ = entity.pop("_uuid") 187 | instance = entity.pop("_instance", None) 188 | 189 | if instance: 190 | # the prior state is another new object... 191 | if isinstance(instance, str): 192 | prechange_data = copy.deepcopy(by_uuid[instance]) 193 | # prior state is a model instance 194 | else: 195 | prechange_data = prechange_data_from_instance(instance) 196 | # merge the prior state that we don't want to overwrite with the new state 197 | # this is also important for custom fields because they do not appear to 198 | # respsect paritial update serialization. 199 | entity = _partially_merge(prechange_data, entity, instance) 200 | changed_data = shallow_compare_dict( 201 | prechange_data, entity, 202 | ) 203 | changed_attrs = sorted(changed_data.keys()) 204 | change = diff_to_change( 205 | object_type, 206 | prechange_data, 207 | entity, 208 | changed_attrs, 209 | new_refs, 210 | ) 211 | 212 | change_set.changes.append(change) 213 | 214 | has_any_changes = False 215 | for change in change_set.changes: 216 | if change.change_type != ChangeType.NOOP: 217 | has_any_changes = True 218 | break 219 | 220 | if not has_any_changes: 221 | change_set.changes = [] 222 | if errors := change_set.validate(): 223 | raise ChangeSetException("Invalid change set", errors) 224 | 225 | 226 | cs = ChangeSetResult( 227 | id=change_set.id, 228 | change_set=change_set, 229 | ) 230 | return cs 231 | 232 | def _partially_merge(prechange_data: dict, postchange_data: dict, instance) -> dict: 233 | """Merge lists and custom_fields rather than replacing the full value...""" 234 | result = {} 235 | for key, value in postchange_data.items(): 236 | # currently we only merge tags, but this could be extended to other reference lists? 237 | if key == "tags": 238 | result[key] = _merge_reference_list(prechange_data.get(key, []), value) 239 | else: 240 | result[key] = value 241 | 242 | # these are fully merged in from the prechange state because 243 | # they don't respect partial update serialization. 244 | if "custom_fields" in postchange_data: 245 | for key, value in prechange_data.get("custom_fields", {}).items(): 246 | if value is not None and key not in postchange_data["custom_fields"]: 247 | result["custom_fields"][key] = value 248 | set_custom_field_defaults(result, instance) 249 | return result 250 | 251 | def _merge_reference_list(prechange_list: list, postchange_list: list) -> list: 252 | """Merge reference lists rather than replacing the full value.""" 253 | result = set(prechange_list) 254 | result.update(postchange_list) 255 | return sort_ints_first(result) 256 | -------------------------------------------------------------------------------- /netbox_diode_plugin/api/permissions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2025 NetBox Labs, Inc. 3 | """Diode NetBox Plugin - API Permissions.""" 4 | 5 | from rest_framework.permissions import BasePermission 6 | 7 | SCOPE_NETBOX_READ = "netbox:read" 8 | SCOPE_NETBOX_WRITE = "netbox:write" 9 | 10 | 11 | class IsAuthenticated(BasePermission): 12 | """Check if the request is authenticated.""" 13 | 14 | def has_permission(self, request, view): 15 | """Check if the request is authenticated.""" 16 | return bool(getattr(request.user, "is_authenticated", False)) 17 | 18 | 19 | def require_scopes(*required_scopes): 20 | """Require one or more OAuth2 token scopes to access a view.""" 21 | 22 | class ScopedPermission(BasePermission): 23 | """Check if the request has the required scopes.""" 24 | 25 | def has_permission(self, request, view): 26 | """Check if the request has the required scopes.""" 27 | scopes = getattr(request, "token_scopes", []) 28 | return all(scope in scopes for scope in required_scopes) 29 | 30 | ScopedPermission.__name__ = f"RequireScopes_{'_'.join(required_scopes)}" 31 | return ScopedPermission 32 | -------------------------------------------------------------------------------- /netbox_diode_plugin/api/serializers.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2025 NetBox Labs, Inc. 3 | """Diode NetBox Plugin - Serializers.""" 4 | 5 | from netbox.api.serializers import NetBoxModelSerializer 6 | 7 | from netbox_diode_plugin.models import Setting 8 | 9 | 10 | class SettingSerializer(NetBoxModelSerializer): 11 | """Setting Serializer.""" 12 | 13 | class Meta: 14 | """Meta class.""" 15 | 16 | model = Setting 17 | fields = ( 18 | "id", 19 | "diode_target", 20 | "custom_fields", 21 | "created", 22 | "last_updated", 23 | ) 24 | -------------------------------------------------------------------------------- /netbox_diode_plugin/api/supported_models.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2025 NetBox Labs Inc 3 | """NetBox Diode Data - API supported models.""" 4 | 5 | import importlib 6 | import logging 7 | import time 8 | from functools import lru_cache 9 | from typing import List, Type 10 | 11 | from django.apps import apps 12 | from django.db import models 13 | from django.db.models import ManyToOneRel 14 | from django.db.models.fields import NOT_PROVIDED 15 | from rest_framework import serializers 16 | from utilities.api import get_serializer_for_model as netbox_get_serializer_for_model 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | # Supported apps 21 | SUPPORTED_APPS = [ 22 | "circuits", 23 | "dcim", 24 | "extras", 25 | "ipam", 26 | "virtualization", 27 | "vpn", 28 | "wireless", 29 | "tenancy", 30 | ] 31 | 32 | # Models that are not supported 33 | EXCLUDED_MODELS = [ 34 | "TaggedItem", 35 | "Subscription", 36 | "ScriptModule", 37 | "Dashboard", 38 | "Notification", 39 | ] 40 | 41 | 42 | def extract_supported_models() -> dict[str, dict]: 43 | """Extract supported models from NetBox.""" 44 | supported_models = discover_models(SUPPORTED_APPS) 45 | 46 | logger.debug(f"Supported models: {supported_models}") 47 | 48 | models_to_process = supported_models 49 | extracted_models: dict[str, dict] = {} 50 | 51 | start_ts = time.time() 52 | while models_to_process: 53 | model = models_to_process.pop() 54 | try: 55 | fields, related_models = get_model_fields(model) 56 | if not fields: 57 | continue 58 | 59 | prerequisites = get_prerequisites(model, fields) 60 | object_type = f"{model._meta.app_label}.{model._meta.model_name}" 61 | extracted_models[object_type] = { 62 | "fields": fields, 63 | "prerequisites": prerequisites, 64 | "model": model, 65 | } 66 | for related_model in related_models: 67 | related_object_type = f"{related_model._meta.app_label}.{related_model._meta.model_name}" 68 | if ( 69 | related_object_type not in extracted_models 70 | and related_object_type not in models_to_process 71 | ): 72 | models_to_process.append(related_model) 73 | except Exception as e: 74 | logger.error(f"extract_supported_models: {model.__name__} error: {e}") 75 | 76 | finish_ts = time.time() 77 | lapsed_millis = (finish_ts - start_ts) * 1000 78 | logger.info( 79 | f"done extracting supported models in {lapsed_millis:.2f} milliseconds - extracted_models: {len(extracted_models)}" 80 | ) 81 | 82 | return extracted_models 83 | 84 | 85 | def get_prerequisites(model_class, fields) -> List[dict[str, str]]: 86 | """Get the prerequisite models for the model.""" 87 | prerequisites: List[dict[str, str]] = [] 88 | prerequisite_models = getattr(model_class, "prerequisite_models", []) 89 | 90 | for prereq in prerequisite_models: 91 | prereq_model = apps.get_model(prereq) 92 | 93 | for field_name, field_info in fields.items(): 94 | related_model = field_info.get("related_model") 95 | prerequisite_info = { 96 | "field_name": field_name, 97 | "prerequisite_model": prereq_model, 98 | } 99 | if ( 100 | prerequisite_info not in prerequisites 101 | and related_model 102 | and related_model.get("model_class_name") == prereq_model.__name__ 103 | ): 104 | prerequisites.append(prerequisite_info) 105 | break 106 | 107 | return prerequisites 108 | 109 | 110 | @lru_cache(maxsize=128) 111 | def get_model_fields(model_class) -> tuple[dict, list]: 112 | """Get the fields for the model ordered as they are in the serializer.""" 113 | related_models_to_process = [] 114 | 115 | # Skip unsupported apps and excluded models 116 | if ( 117 | model_class._meta.app_label not in SUPPORTED_APPS 118 | or model_class.__name__ in EXCLUDED_MODELS 119 | ): 120 | return {}, [] 121 | 122 | try: 123 | # Get serializer fields to maintain order 124 | serializer_class = get_serializer_for_model(model_class) 125 | serializer_fields = serializer_class().get_fields() 126 | serializer_fields_names = list(serializer_fields.keys()) 127 | except Exception as e: 128 | logger.error(f"Error getting serializer fields for model {model_class}: {e}") 129 | return {}, [] 130 | 131 | # Get all model fields 132 | model_fields = { 133 | field.name: field 134 | for field in model_class._meta.get_fields() 135 | if field.__class__.__name__ not in ["CounterCacheField", "GenericRelation"] 136 | } 137 | 138 | # Reorder fields to match serializer order 139 | ordered_fields = { 140 | field_name: model_fields[field_name] 141 | for field_name in serializer_fields_names 142 | if field_name in model_fields 143 | } 144 | 145 | # Add remaining fields 146 | ordered_fields.update( 147 | { 148 | field_name: field 149 | for field_name, field in model_fields.items() 150 | if field_name not in ordered_fields 151 | } 152 | ) 153 | 154 | fields_info = {} 155 | 156 | for field_name, field in ordered_fields.items(): 157 | field_info = { 158 | "type": field.get_internal_type(), 159 | "required": not field.null and not field.blank, 160 | "is_many_to_one_rel": isinstance(field, ManyToOneRel), 161 | "is_numeric": field.get_internal_type() 162 | in [ 163 | "IntegerField", 164 | "FloatField", 165 | "DecimalField", 166 | "PositiveIntegerField", 167 | "PositiveSmallIntegerField", 168 | "SmallIntegerField", 169 | "BigIntegerField", 170 | ], 171 | } 172 | 173 | # Handle default values 174 | default_value = None 175 | if hasattr(field, "default"): 176 | default_value = ( 177 | field.default if field.default not in (NOT_PROVIDED, dict) else None 178 | ) 179 | field_info["default"] = default_value 180 | 181 | # Handle related fields 182 | if field.is_relation: 183 | related_model = field.related_model 184 | if related_model: 185 | related_model_key = ( 186 | f"{related_model._meta.app_label}.{related_model._meta.model_name}" 187 | ) 188 | related_model_info = { 189 | "app_label": related_model._meta.app_label, 190 | "model_name": related_model._meta.model_name, 191 | "model_class_name": related_model.__name__, 192 | "object_type": related_model_key, 193 | "filters": get_field_filters(model_class, field_name), 194 | } 195 | field_info["related_model"] = related_model_info 196 | if ( 197 | related_model.__name__ not in EXCLUDED_MODELS 198 | and related_model not in related_models_to_process 199 | ): 200 | related_models_to_process.append(related_model) 201 | 202 | fields_info[field_name] = field_info 203 | 204 | return fields_info, related_models_to_process 205 | 206 | 207 | @lru_cache(maxsize=128) 208 | def get_field_filters(model_class, field_name): 209 | """Get filters for a field.""" 210 | if hasattr(model_class, "_netbox_private"): 211 | return None 212 | 213 | try: 214 | filterset_name = f"{model_class.__name__}FilterSet" 215 | filterset_module = importlib.import_module( 216 | f"{model_class._meta.app_label}.filtersets" 217 | ) 218 | filterset_class = getattr(filterset_module, filterset_name) 219 | 220 | _filters = set() 221 | field_filters = [] 222 | for filter_name, filter_instance in filterset_class.get_filters().items(): 223 | filter_by = getattr(filter_instance, "field_name", None) 224 | filter_field_extra = getattr(filter_instance, "extra", None) 225 | 226 | if not filter_name.startswith(field_name) or filter_by.endswith("_id"): 227 | continue 228 | 229 | if filter_by and filter_by not in _filters: 230 | _filters.add(filter_by) 231 | field_filters.append( 232 | { 233 | "filter_by": filter_by, 234 | "filter_to_field_name": ( 235 | filter_field_extra.get("to_field_name", None) 236 | if filter_field_extra 237 | else None 238 | ), 239 | } 240 | ) 241 | return list(field_filters) if field_filters else None 242 | except Exception as e: 243 | logger.error( 244 | f"Error getting field filters for model {model_class.__name__} and field {field_name}: {e}" 245 | ) 246 | return None 247 | 248 | 249 | @lru_cache(maxsize=128) 250 | def get_serializer_for_model(model, prefix=""): 251 | """Cached wrapper for NetBox's get_serializer_for_model function.""" 252 | return netbox_get_serializer_for_model(model, prefix) 253 | 254 | 255 | def discover_models(root_packages: List[str]) -> list[Type[models.Model]]: 256 | """Discovers all model classes in specified root packages.""" 257 | discovered_models = [] 258 | 259 | # Look through all modules that might contain serializers 260 | module_names = [ 261 | "api.serializers", 262 | ] 263 | 264 | for root_package in root_packages: 265 | logger.debug(f"Searching in root package: {root_package}") 266 | 267 | for module_name in module_names: 268 | full_module_path = f"{root_package}.{module_name}" 269 | try: 270 | module = __import__(full_module_path, fromlist=["*"]) 271 | except ImportError: 272 | logger.error(f"Could not import {full_module_path}") 273 | continue 274 | 275 | # Find all serializer classes in the module 276 | for serializer_name in dir(module): 277 | serializer = getattr(module, serializer_name) 278 | if ( 279 | isinstance(serializer, type) 280 | and issubclass(serializer, serializers.Serializer) 281 | and serializer != serializers.Serializer 282 | and serializer != serializers.ModelSerializer 283 | and hasattr(serializer, "Meta") 284 | and hasattr(serializer.Meta, "model") 285 | ): 286 | model = serializer.Meta.model 287 | if model not in discovered_models: 288 | discovered_models.append(model) 289 | logger.debug( 290 | f"Discovered model: {model.__module__}.{model.__name__}" 291 | ) 292 | 293 | return discovered_models 294 | -------------------------------------------------------------------------------- /netbox_diode_plugin/api/urls.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2025 NetBox Labs, Inc. 3 | """Diode NetBox Plugin - API URLs.""" 4 | 5 | from django.urls import include, path 6 | from netbox.api.routers import NetBoxRouter 7 | 8 | from .views import ApplyChangeSetView, GenerateDiffView 9 | 10 | router = NetBoxRouter() 11 | 12 | urlpatterns = [ 13 | path("apply-change-set/", ApplyChangeSetView.as_view()), 14 | path("generate-diff/", GenerateDiffView.as_view()), 15 | path("", include(router.urls)), 16 | ] 17 | -------------------------------------------------------------------------------- /netbox_diode_plugin/api/views.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2025 NetBox Labs, Inc. 3 | """Diode NetBox Plugin - API Views.""" 4 | import logging 5 | import re 6 | 7 | from django.apps import apps 8 | from django.db import transaction 9 | from rest_framework import views 10 | from rest_framework.exceptions import ValidationError 11 | from rest_framework.response import Response 12 | 13 | from netbox_diode_plugin.api.applier import apply_changeset 14 | from netbox_diode_plugin.api.authentication import DiodeOAuth2Authentication 15 | from netbox_diode_plugin.api.common import ( 16 | Change, 17 | ChangeSet, 18 | ChangeSetException, 19 | ChangeSetResult, 20 | ) 21 | from netbox_diode_plugin.api.differ import generate_changeset 22 | from netbox_diode_plugin.api.permissions import ( 23 | SCOPE_NETBOX_READ, 24 | SCOPE_NETBOX_WRITE, 25 | IsAuthenticated, 26 | require_scopes, 27 | ) 28 | 29 | logger = logging.getLogger("netbox.diode_data") 30 | 31 | # Try to import Branch model at module level 32 | Branch = None 33 | try: 34 | if apps.is_installed("netbox_branching"): 35 | from netbox_branching.models import Branch 36 | except ImportError: 37 | logger.warning( 38 | "netbox_branching plugin is installed but models could not be imported" 39 | ) 40 | 41 | 42 | def get_valid_entity_keys(model_name): 43 | """ 44 | Get the valid entity keys for a model name. 45 | 46 | This can be snake or lowerCamel case (both are valid for protoJSON) 47 | """ 48 | s = re.sub(r"([A-Z0-9]{2,})([A-Z])([a-z])", r"\1_\2\3", model_name) 49 | s = re.sub(r"([a-z])([A-Z])", r"\1_\2", s) 50 | snake = re.sub(r"_+", "_", s.lower()) # snake 51 | upperCamel = "".join( 52 | [word.capitalize() for word in snake.split("_")] 53 | ) # upperCamelCase 54 | lowerCamel = upperCamel[0].lower() + upperCamel[1:] # lowerCamelCase 55 | 56 | return (snake, lowerCamel) 57 | 58 | 59 | class GenerateDiffView(views.APIView): 60 | """GenerateDiff view.""" 61 | 62 | authentication_classes = [DiodeOAuth2Authentication] 63 | permission_classes = [IsAuthenticated, require_scopes(SCOPE_NETBOX_READ)] 64 | 65 | def post(self, request, *args, **kwargs): 66 | """Generate diff for entity.""" 67 | try: 68 | return self._post(request, *args, **kwargs) 69 | except Exception: 70 | import traceback 71 | 72 | traceback.print_exc() 73 | raise 74 | 75 | def _post(self, request, *args, **kwargs): 76 | entity = request.data.get("entity") 77 | object_type = request.data.get("object_type") 78 | 79 | if not entity: 80 | raise ValidationError("Entity is required") 81 | if not object_type: 82 | raise ValidationError("Object type is required") 83 | 84 | app_label, model_name = object_type.split(".") 85 | model_class = apps.get_model(app_label, model_name) 86 | 87 | for entity_key in get_valid_entity_keys(model_class.__name__): 88 | original_entity_data = entity.get(entity_key) 89 | if original_entity_data: 90 | break 91 | 92 | if original_entity_data is None: 93 | raise ValidationError( 94 | f"No data found for {entity_key} in entity got: {entity.keys()}" 95 | ) 96 | 97 | try: 98 | result = generate_changeset(original_entity_data, object_type) 99 | except ChangeSetException as e: 100 | logger.error(f"Error generating change set: {e}") 101 | result = ChangeSetResult( 102 | errors=e.errors, 103 | ) 104 | return Response(result.to_dict(), status=result.get_status_code()) 105 | 106 | branch_schema_id = request.headers.get("X-NetBox-Branch") 107 | 108 | # If branch schema ID is provided and branching plugin is installed, get branch name 109 | if branch_schema_id and Branch is not None: 110 | try: 111 | branch = Branch.objects.get(schema_id=branch_schema_id) 112 | result.change_set.branch = {"id": branch.schema_id, "name": branch.name} 113 | except Branch.DoesNotExist: 114 | sanitized_branch_id = branch_schema_id.replace('\n', '').replace('\r', '') 115 | logger.warning(f"Branch with ID {sanitized_branch_id} does not exist") 116 | 117 | return Response(result.to_dict(), status=result.get_status_code()) 118 | 119 | 120 | class ApplyChangeSetView(views.APIView): 121 | """ApplyChangeSet view.""" 122 | 123 | authentication_classes = [DiodeOAuth2Authentication] 124 | permission_classes = [IsAuthenticated, require_scopes(SCOPE_NETBOX_WRITE)] 125 | 126 | def post(self, request, *args, **kwargs): 127 | """Apply change set for entity.""" 128 | try: 129 | return self._post(request, *args, **kwargs) 130 | except Exception: 131 | import traceback 132 | 133 | traceback.print_exc() 134 | raise 135 | 136 | def _post(self, request, *args, **kwargs): 137 | data = request.data.copy() 138 | 139 | changes = [] 140 | if "changes" in data: 141 | changes = [ 142 | Change( 143 | change_type=change.get("change_type"), 144 | object_type=change.get("object_type"), 145 | object_id=change.get("object_id"), 146 | ref_id=change.get("ref_id"), 147 | data=change.get("data"), 148 | before=change.get("before"), 149 | new_refs=change.get("new_refs", []), 150 | ) 151 | for change in data["changes"] 152 | ] 153 | change_set = ChangeSet( 154 | id=data.get("id"), 155 | changes=changes, 156 | ) 157 | try: 158 | with transaction.atomic(): 159 | result = apply_changeset(change_set, request) 160 | except ChangeSetException as e: 161 | logger.error(f"Error applying change set: {e}") 162 | result = ChangeSetResult( 163 | id=change_set.id, 164 | errors=e.errors, 165 | ) 166 | 167 | return Response(result.to_dict(), status=result.get_status_code()) 168 | -------------------------------------------------------------------------------- /netbox_diode_plugin/client.py: -------------------------------------------------------------------------------- 1 | # !/usr/bin/env python 2 | # Copyright 2025 NetBox Labs, Inc. 3 | """Diode NetBox Plugin - Client.""" 4 | 5 | import logging 6 | 7 | from netbox_diode_plugin.diode.clients import get_api_client 8 | 9 | logger = logging.getLogger("netbox.diode_data") 10 | 11 | 12 | def create_client(request, client_name: str, scope: str): 13 | """Create client.""" 14 | logger.info(f"Creating client {client_name} with scope {scope}") 15 | return get_api_client().create_client(client_name, scope) 16 | 17 | 18 | def delete_client(request, client_id: str): 19 | """Delete client.""" 20 | sanitized_client_id = client_id.replace("\n", "").replace("\r", "") 21 | logger.info(f"Deleting client {sanitized_client_id}") 22 | return get_api_client().delete_client(client_id) 23 | 24 | 25 | def list_clients(request): 26 | """List clients.""" 27 | logger.info("Listing clients") 28 | response = get_api_client().list_clients() 29 | return response["data"] 30 | 31 | 32 | def get_client(request, client_id: str): 33 | """Get client.""" 34 | sanitized_client_id = client_id.replace("\n", "").replace("\r", "") 35 | logger.info(f"Getting client {sanitized_client_id}") 36 | return get_api_client().get_client(client_id) 37 | -------------------------------------------------------------------------------- /netbox_diode_plugin/diode/clients.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2025 NetBox Labs Inc 3 | """Diode NetBox Plugin - Diode - Auth.""" 4 | 5 | import datetime 6 | import json 7 | import logging 8 | import re 9 | import threading 10 | from dataclasses import dataclass 11 | from urllib.parse import urlencode 12 | 13 | import requests 14 | 15 | from netbox_diode_plugin.plugin_config import ( 16 | get_diode_auth_base_url, 17 | get_diode_credentials, 18 | get_diode_max_auth_retries, 19 | ) 20 | 21 | SCOPE_DIODE_READ = "diode:read" 22 | SCOPE_DIODE_WRITE = "diode:write" 23 | 24 | logger = logging.getLogger("netbox.diode_data") 25 | 26 | valid_client_id_re = re.compile(r"^[a-zA-Z0-9_-]{1,64}$") 27 | 28 | _client = None 29 | _client_lock = threading.Lock() 30 | def get_api_client(): 31 | """Get the client API client.""" 32 | global _client 33 | global _client_lock 34 | 35 | with _client_lock: 36 | if _client is None: 37 | client_id, client_secret = get_diode_credentials() 38 | if not client_id: 39 | raise ClientAPIError( 40 | "Please update the plugin configuration to access this feature.\nMissing netbox to diode client id.", 500) 41 | if not client_secret: 42 | raise ClientAPIError( 43 | "Please update the plugin configuration to access this feature.\nMissing netbox to diode client secret.", 500) 44 | max_auth_retries = get_diode_max_auth_retries() 45 | _client = ClientAPI( 46 | base_url=get_diode_auth_base_url(), 47 | client_id=client_id, 48 | client_secret=client_secret, 49 | max_auth_retries=max_auth_retries, 50 | ) 51 | return _client 52 | 53 | 54 | class ClientAPIError(Exception): 55 | """Client API Error.""" 56 | 57 | def __init__(self, message: str, status_code: int = 500): 58 | """Initialize the ClientAPIError.""" 59 | self.message = message 60 | self.status_code = status_code 61 | super().__init__(self.message) 62 | 63 | def is_auth_error(self) -> bool: 64 | """Check if the error is an authentication error.""" 65 | return self.status_code == 401 or self.status_code == 403 66 | 67 | class ClientAPI: 68 | """Manages Diode Clients.""" 69 | 70 | def __init__(self, base_url: str, client_id: str, client_secret: str, max_auth_retries: int = 2): 71 | """Initialize the ClientAPI.""" 72 | self.base_url = base_url 73 | self.client_id = client_id 74 | self.client_secret = client_secret 75 | 76 | self._max_auth_retries = max_auth_retries 77 | self._client_auth_token = None 78 | self._client_auth_token_lock = threading.Lock() 79 | 80 | def create_client(self, name: str, scope: str) -> dict: 81 | """Create a client.""" 82 | for attempt in range(self._max_auth_retries): 83 | token = None 84 | try: 85 | token = self._get_token() 86 | url = self.base_url + "/clients" 87 | headers = {"Authorization": f"Bearer {token}"} 88 | data = { 89 | "client_name": name, 90 | "scope": scope, 91 | } 92 | response = requests.post(url, json=data, headers=headers) 93 | if response.status_code != 201: 94 | raise ClientAPIError("Failed to create client", response.status_code) 95 | return response.json() 96 | except ClientAPIError as e: 97 | if e.is_auth_error() and attempt < self._max_auth_retries - 1: 98 | logger.info(f"Retrying create_client due to unauthenticated error, attempt {attempt + 1}") 99 | self._mark_client_auth_token_invalid(token) 100 | continue 101 | raise 102 | raise ClientAPIError("Failed to create client: unexpected state", 500) 103 | 104 | def get_client(self, client_id: str) -> dict: 105 | """Get a client.""" 106 | if not valid_client_id_re.match(client_id): 107 | raise ValueError(f"Invalid client ID: {client_id}") 108 | 109 | for attempt in range(self._max_auth_retries): 110 | token = None 111 | try: 112 | token = self._get_token() 113 | url = self.base_url + f"/clients/{client_id}" 114 | headers = {"Authorization": f"Bearer {token}"} 115 | response = requests.get(url, headers=headers) 116 | if response.status_code == 401 or response.status_code == 403: 117 | raise ClientAPIError(f"Failed to get client {client_id}", response.status_code) 118 | if response.status_code != 200: 119 | raise ClientAPIError(f"Failed to get client {client_id}", response.status_code) 120 | return response.json() 121 | except ClientAPIError as e: 122 | if e.is_auth_error() and attempt < self._max_auth_retries - 1: 123 | logger.info(f"Retrying delete_client due to unauthenticated error, attempt {attempt + 1}") 124 | self._mark_client_auth_token_invalid(token) 125 | continue 126 | raise 127 | raise ClientAPIError(f"Failed to get client {client_id}: unexpected state") 128 | 129 | def delete_client(self, client_id: str) -> None: 130 | """Delete a client.""" 131 | if not valid_client_id_re.match(client_id): 132 | raise ValueError(f"Invalid client ID: {client_id}") 133 | 134 | for attempt in range(self._max_auth_retries): 135 | token = None 136 | try: 137 | token = self._get_token() 138 | url = self.base_url + f"/clients/{client_id}" 139 | headers = {"Authorization": f"Bearer {token}"} 140 | response = requests.delete(url, headers=headers) 141 | if response.status_code != 204: 142 | raise ClientAPIError(f"Failed to delete client {client_id}", response.status_code) 143 | return 144 | except ClientAPIError as e: 145 | if e.is_auth_error() and attempt < self._max_auth_retries - 1: 146 | logger.info(f"Retrying delete_client due to unauthenticated error, attempt {attempt + 1}") 147 | self._mark_client_auth_token_invalid(token) 148 | continue 149 | raise 150 | raise ClientAPIError(f"Failed to delete client {client_id}: unexpected state") 151 | 152 | 153 | def list_clients(self, page_token: str | None = None, page_size: int | None = None) -> list[dict]: 154 | """List all clients.""" 155 | for attempt in range(self._max_auth_retries): 156 | token = None 157 | try: 158 | token = self._get_token() 159 | url = self.base_url + "/clients" 160 | headers = {"Authorization": f"Bearer {token}"} 161 | params = {} 162 | if page_token: 163 | params["page_token"] = page_token 164 | if page_size: 165 | params["page_size"] = page_size 166 | response = requests.get(url, headers=headers, params=params) 167 | if response.status_code != 200: 168 | raise ClientAPIError("Failed to get clients", response.status_code) 169 | return response.json() 170 | except ClientAPIError as e: 171 | if e.is_auth_error() and attempt < self._max_auth_retries - 1: 172 | logger.info(f"Retrying list_clients due to unauthenticated error, attempt {attempt + 1}") 173 | self._mark_client_auth_token_invalid(token) 174 | continue 175 | raise 176 | raise ClientAPIError("Failed to list clients: unexpected state") 177 | 178 | 179 | def _get_token(self) -> str: 180 | """Get a token for the Diode Auth Service.""" 181 | with self._client_auth_token_lock: 182 | if self._client_auth_token: 183 | return self._client_auth_token 184 | self._client_auth_token = self._authenticate() 185 | return self._client_auth_token 186 | 187 | def _mark_client_auth_token_invalid(self, token: str): 188 | """Mark a client auth token as invalid.""" 189 | with self._client_auth_token_lock: 190 | self._client_auth_token = None 191 | 192 | def _authenticate(self) -> str: 193 | """Get a new access token for the Diode Auth Service.""" 194 | headers = {"Content-Type": "application/x-www-form-urlencoded"} 195 | data = urlencode( 196 | { 197 | "grant_type": "client_credentials", 198 | "client_id": self.client_id, 199 | "client_secret": self.client_secret, 200 | "scope": f"{SCOPE_DIODE_READ} {SCOPE_DIODE_WRITE}", 201 | } 202 | ) 203 | url = self.base_url + "/token" 204 | try: 205 | response = requests.post(url, data=data, headers=headers) 206 | except Exception as e: 207 | raise ClientAPIError(f"Failed to obtain access token: {e}", 401) from e 208 | if response.status_code != 200: 209 | raise ClientAPIError(f"Failed to obtain access token: {response.reason}", 401) 210 | 211 | try: 212 | token_info = response.json() 213 | except Exception as e: 214 | raise ClientAPIError(f"Failed to parse access token response: {e}", 401) from e 215 | 216 | access_token = token_info.get("access_token") 217 | if not access_token: 218 | raise ClientAPIError(f"Failed to obtain access token for client {self._client_id}", 401) 219 | 220 | return access_token 221 | 222 | -------------------------------------------------------------------------------- /netbox_diode_plugin/forms.py: -------------------------------------------------------------------------------- 1 | # !/usr/bin/env python 2 | # Copyright 2025 NetBox Labs, Inc. 3 | """Diode NetBox Plugin - Forms.""" 4 | from django import forms 5 | from django.utils.translation import gettext_lazy as _ 6 | from netbox.forms import NetBoxModelForm 7 | from netbox.plugins import get_plugin_config 8 | from utilities.forms.rendering import FieldSet 9 | 10 | from netbox_diode_plugin.models import Setting 11 | 12 | __all__ = ( 13 | "SettingsForm", 14 | "ClientCredentialForm", 15 | ) 16 | 17 | 18 | class SettingsForm(NetBoxModelForm): 19 | """Settings form.""" 20 | 21 | fieldsets = ( 22 | FieldSet( 23 | "diode_target", 24 | ), 25 | ) 26 | 27 | class Meta: 28 | """Meta class.""" 29 | 30 | model = Setting 31 | fields = ("diode_target",) 32 | 33 | def __init__(self, *args, **kwargs): 34 | """Initialize the form.""" 35 | super().__init__(*args, **kwargs) 36 | 37 | diode_target_override = get_plugin_config( 38 | "netbox_diode_plugin", "diode_target_override" 39 | ) 40 | 41 | if diode_target_override: 42 | self.fields["diode_target"].disabled = True 43 | self.fields["diode_target"].help_text = ( 44 | "This field is not allowed to be modified." 45 | ) 46 | 47 | 48 | class ClientCredentialForm(forms.Form): 49 | """Form for adding client credentials.""" 50 | 51 | client_name = forms.CharField( 52 | label=_("Client Name"), 53 | required=True, 54 | help_text=_("Enter a name for the client credential that will be created for authentication to the Diode ingestion service."), 55 | widget=forms.TextInput(attrs={"class": "form-control"}), 56 | ) 57 | -------------------------------------------------------------------------------- /netbox_diode_plugin/migrations/0001_squashed_0005.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2025 NetBox Labs, Inc. 3 | """Diode NetBox Plugin - Database migrations.""" 4 | 5 | import utilities.json 6 | from django.db import migrations, models 7 | from netbox.plugins import get_plugin_config 8 | 9 | 10 | def create_settings_entity(apps, schema_editor): 11 | """Create a Setting entity.""" 12 | Setting = apps.get_model("netbox_diode_plugin", "Setting") 13 | 14 | default_diode_target = get_plugin_config("netbox_diode_plugin", "diode_target") 15 | diode_target = get_plugin_config( 16 | "netbox_diode_plugin", "diode_target_override", default_diode_target 17 | ) 18 | 19 | Setting.objects.create(diode_target=diode_target) 20 | 21 | 22 | class Migration(migrations.Migration): 23 | """Initial migration.""" 24 | 25 | replaces = [ 26 | ("netbox_diode_plugin", "0001_initial"), 27 | ("netbox_diode_plugin", "0002_setting"), 28 | ("netbox_diode_plugin", "0003_clear_permissions"), 29 | ("netbox_diode_plugin", "0004_rename_legacy_users"), 30 | ("netbox_diode_plugin", "0005_revoke_superuser_status"), 31 | ] 32 | 33 | initial = True 34 | 35 | dependencies = [ 36 | ("contenttypes", "0001_initial"), 37 | ("users", "0006_custom_group_model"), 38 | ] 39 | 40 | operations = [ 41 | migrations.CreateModel( 42 | name="Setting", 43 | fields=[ 44 | ( 45 | "id", 46 | models.BigAutoField( 47 | auto_created=True, primary_key=True, serialize=False 48 | ), 49 | ), 50 | ("created", models.DateTimeField(auto_now_add=True, null=True)), 51 | ("last_updated", models.DateTimeField(auto_now=True, null=True)), 52 | ( 53 | "custom_field_data", 54 | models.JSONField( 55 | blank=True, 56 | default=dict, 57 | encoder=utilities.json.CustomFieldJSONEncoder, 58 | ), 59 | ), 60 | ("diode_target", models.CharField(max_length=255)), 61 | ], 62 | options={ 63 | "verbose_name": "Setting", 64 | "verbose_name_plural": "Diode Settings", 65 | }, 66 | ), 67 | migrations.RunPython( 68 | code=create_settings_entity, reverse_code=migrations.RunPython.noop 69 | ), 70 | ] 71 | -------------------------------------------------------------------------------- /netbox_diode_plugin/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2025 NetBox Labs, Inc. 3 | """Diode Netbox Plugin - Database migrations.""" 4 | -------------------------------------------------------------------------------- /netbox_diode_plugin/models.py: -------------------------------------------------------------------------------- 1 | # !/usr/bin/env python 2 | # Copyright 2025 NetBox Labs, Inc. 3 | """Diode NetBox Plugin - Models.""" 4 | from urllib.parse import urlparse 5 | 6 | from django.core.exceptions import ValidationError 7 | from django.db import models 8 | from django.urls import reverse 9 | from netbox.models import NetBoxModel 10 | 11 | 12 | def diode_target_validator(target): 13 | """Diode target validator.""" 14 | try: 15 | parsed_target = urlparse(target) 16 | 17 | if parsed_target.scheme not in ["grpc", "grpcs"]: 18 | raise ValueError("target should start with grpc:// or grpcs://") 19 | except ValueError as exc: 20 | raise ValidationError(exc) 21 | 22 | 23 | class Setting(NetBoxModel): 24 | """Setting model.""" 25 | 26 | diode_target = models.CharField(max_length=255, validators=[diode_target_validator]) 27 | 28 | class Meta: 29 | """Meta class.""" 30 | 31 | verbose_name = "Settings" 32 | verbose_name_plural = "Settings" 33 | 34 | def __str__(self): 35 | """Return string representation.""" 36 | return "" 37 | 38 | def get_absolute_url(self): 39 | """Return absolute URL.""" 40 | return reverse("plugins:netbox_diode_plugin:settings") 41 | 42 | 43 | class ClientCredentials(models.Model): 44 | """Dummy model to allow for permissions, saved filters, etc..""" 45 | 46 | class Meta: 47 | """Meta class.""" 48 | 49 | managed = False 50 | 51 | default_permissions = () 52 | 53 | permissions = ( 54 | ("view_clientcredentials", "Can view Client Credentials"), 55 | ("add_clientcredentials", "Can perform actions on Client Credentials"), 56 | ) 57 | 58 | -------------------------------------------------------------------------------- /netbox_diode_plugin/navigation.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2025 NetBox Labs, Inc. 3 | """Diode NetBox Plugin - Navigation.""" 4 | 5 | from django.utils.translation import gettext as _ 6 | from netbox.plugins import PluginMenu, PluginMenuItem 7 | 8 | _diode_menu_items = ( 9 | PluginMenuItem( 10 | link="plugins:netbox_diode_plugin:settings", 11 | link_text=_("Settings"), 12 | staff_only= True, 13 | ), 14 | PluginMenuItem( 15 | link="plugins:netbox_diode_plugin:client_credential_list", 16 | link_text=_("Client Credentials"), 17 | staff_only= True, 18 | ), 19 | ) 20 | 21 | menu = PluginMenu( 22 | label="Diode", 23 | groups=( 24 | (_("Diode"), _diode_menu_items), 25 | ), 26 | icon_class="mdi mdi-upload", 27 | ) 28 | -------------------------------------------------------------------------------- /netbox_diode_plugin/plugin_config.py: -------------------------------------------------------------------------------- 1 | # !/usr/bin/env python 2 | # Copyright 2025 NetBox Labs, Inc. 3 | """Diode NetBox Plugin - Plugin Settings.""" 4 | 5 | import logging 6 | import os 7 | from urllib.parse import urlparse 8 | 9 | from django.contrib.auth import get_user_model 10 | from netbox.plugins import get_plugin_config 11 | 12 | __all__ = ( 13 | "get_diode_auth_introspect_url", 14 | "get_diode_user", 15 | ) 16 | 17 | User = get_user_model() 18 | 19 | logger = logging.getLogger("netbox.diode_data") 20 | 21 | def _parse_diode_target(target: str) -> tuple[str, str, bool]: 22 | """Parse the target into authority, path and tls_verify.""" 23 | parsed_target = urlparse(target) 24 | 25 | if parsed_target.scheme not in ["grpc", "grpcs"]: 26 | raise ValueError("target should start with grpc:// or grpcs://") 27 | 28 | tls_verify = parsed_target.scheme == "grpcs" 29 | 30 | authority = parsed_target.netloc 31 | 32 | return authority, parsed_target.path, tls_verify 33 | 34 | 35 | def get_diode_auth_introspect_url(): 36 | """Returns the Diode Auth introspect URL.""" 37 | diode_auth_base_url = get_diode_auth_base_url() 38 | return f"{diode_auth_base_url}/introspect" 39 | 40 | def get_diode_auth_base_url(): 41 | """Returns the Diode Auth service base URL.""" 42 | diode_target = get_plugin_config("netbox_diode_plugin", "diode_target") 43 | diode_target_override = get_plugin_config( 44 | "netbox_diode_plugin", "diode_target_override" 45 | ) 46 | 47 | authority, path, tls_verify = _parse_diode_target( 48 | diode_target_override or diode_target 49 | ) 50 | scheme = "https" if tls_verify else "http" 51 | path = path.rstrip("/") 52 | 53 | return f"{scheme}://{authority}{path}/auth" 54 | 55 | def get_diode_credentials(): 56 | """Returns the Diode credentials.""" 57 | client_id = get_plugin_config("netbox_diode_plugin", "netbox_to_diode_client_id") 58 | secrets_path = get_plugin_config("netbox_diode_plugin", "secrets_path") 59 | secret_name = get_plugin_config("netbox_diode_plugin", "netbox_to_diode_client_secret_name") 60 | client_secret = get_plugin_config("netbox_diode_plugin", "netbox_to_diode_client_secret") 61 | 62 | if not client_secret: 63 | secret_file = os.path.join(secrets_path, secret_name) 64 | client_secret = _read_secret(secret_file, client_secret) 65 | 66 | return client_id, client_secret 67 | 68 | def get_diode_max_auth_retries(): 69 | """Returns the Diode max auth retries.""" 70 | return get_plugin_config("netbox_diode_plugin", "diode_max_auth_retries") 71 | 72 | # Read secret from file 73 | def _read_secret(secret_file: str, default: str | None = None) -> str | None: 74 | try: 75 | f = open(secret_file, encoding='utf-8') 76 | except OSError: 77 | return default 78 | else: 79 | with f: 80 | return f.readline().strip() 81 | 82 | def get_diode_user(): 83 | """Returns the Diode user.""" 84 | diode_username = get_plugin_config("netbox_diode_plugin", "diode_username") 85 | 86 | try: 87 | diode_user = User.objects.get(username=diode_username) 88 | except User.DoesNotExist: 89 | diode_user = User.objects.create(username=diode_username, is_active=True) 90 | 91 | return diode_user 92 | -------------------------------------------------------------------------------- /netbox_diode_plugin/tables.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2024 NetBox Labs Inc 3 | """Diode NetBox Plugin - Tables.""" 4 | import logging 5 | 6 | import django_tables2 as tables 7 | from django.urls import reverse 8 | from django.utils.dateparse import parse_datetime 9 | from django.utils.safestring import mark_safe 10 | from django.utils.translation import gettext_lazy as _ 11 | from netbox.tables import BaseTable, columns 12 | 13 | 14 | class ClientCredentialsTable(BaseTable): 15 | """Client credentials table.""" 16 | 17 | label = tables.Column( 18 | verbose_name=_("Name"), 19 | accessor="client_name", 20 | orderable=False, 21 | ) 22 | client_id = tables.Column( 23 | verbose_name=_("Client ID"), 24 | accessor="client_id", 25 | orderable=False, 26 | ) 27 | created_at = columns.DateTimeColumn( 28 | verbose_name=_("Created"), 29 | accessor="created_at", 30 | orderable=False, 31 | ) 32 | client_secret = tables.Column( 33 | verbose_name=_("Client Secret"), 34 | empty_values=(), 35 | orderable=False, 36 | ) 37 | actions = tables.Column( 38 | verbose_name=_(""), 39 | orderable=False, 40 | empty_values=(), 41 | attrs={ 42 | "td": { 43 | "class": "text-end", 44 | } 45 | }, 46 | ) 47 | 48 | exempt_columns = ("actions") 49 | embedded = False 50 | 51 | class Meta: 52 | """Meta class.""" 53 | 54 | attrs = { 55 | "class": "table table-hover object-list", 56 | "td": {"class": "align-middle"}, 57 | } 58 | fields = None 59 | default_columns = ( 60 | "label", 61 | "client_id", 62 | "created_at", 63 | "client_secret", 64 | "actions", 65 | ) 66 | 67 | empty_text = _("No Client Credentials to display") 68 | footer = False 69 | 70 | def render_client_secret(self, value): 71 | """Render client secret.""" 72 | return "*****" 73 | 74 | def render_created_at(self, value): 75 | """Render created at.""" 76 | if value: 77 | return parse_datetime(value) 78 | return "-" 79 | 80 | def render_actions(self, record): 81 | """Render actions.""" 82 | delete_url = reverse( 83 | "plugins:netbox_diode_plugin:client_credential_delete", 84 | kwargs={"client_credential_id": record["client_id"]}, 85 | ) 86 | 87 | buttons = f""" 88 | 95 | 96 | 97 | """ # noqa: E501 98 | 99 | return mark_safe(buttons) 100 | -------------------------------------------------------------------------------- /netbox_diode_plugin/templates/diode/client_credential_add.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object_edit.html' %} 2 | {% load form_helpers %} 3 | {% load i18n %} 4 | 5 | {% block title %}{% trans "Add Client Credential" %}{% endblock %} 6 | 7 | {% block form %} 8 | {% render_form form %} 9 | {% endblock %} 10 | 11 | {% block buttons %} 12 | {% trans "Cancel" %} 13 |
14 | 17 |
18 | {% endblock buttons %} 19 | -------------------------------------------------------------------------------- /netbox_diode_plugin/templates/diode/client_credential_delete.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/_base.html' %} 2 | {% load helpers %} 3 | {% load form_helpers %} 4 | {% load i18n %} 5 | 6 | {% comment %} 7 | Blocks: 8 | - title: Page title 9 | - content: Primary page content 10 | 11 | Context: 12 | - object: Python instance of the object being deleted 13 | - form: The delete confirmation form 14 | - form_url: URL for form submission (optional; defaults to current path) 15 | - return_url: The URL to which the user is redirected after submitting the form 16 | {% endcomment %} 17 | 18 | {% block title %} 19 | {% trans "Delete" %} {{ object.client_name }}? 20 | {% endblock %} 21 | 22 | {% block content %} 23 | 30 | {% endblock %} 31 | -------------------------------------------------------------------------------- /netbox_diode_plugin/templates/diode/client_credential_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object_list.html' %} 2 | {% load static %} 3 | {% load buttons %} 4 | {% load helpers %} 5 | {% load humanize %} 6 | {% load i18n %} 7 | 8 | 9 | {% block page-header %} 10 |
11 |
12 | 13 | {# Title #} 14 |
15 |

{% block pagetitle %}{% trans 'Client Credentials' %}{% endblock %}

16 |
17 | 18 | {# Controls #} 19 |
20 | {% block controls %} 21 | 26 | {% endblock controls %} 27 |
28 | 29 |
30 |
31 | {% endblock %} 32 | 33 | {% block title %}{% trans "Client Credentials" %}{% endblock %} 34 | 35 | {% block content %} 36 | {# Object list tab #} 37 |
38 |
39 | {% csrf_token %} 40 | {# "Select all" form #} 41 | {% if table.paginator.num_pages > 1 %} 42 |
43 |
44 |
45 |
46 | 47 | 52 |
53 |
54 |
55 |
56 |
57 |
58 | {% endif %} 59 | 60 |
61 | {% csrf_token %} 62 | 63 | 64 | {# Objects table #} 65 |
66 |
67 | {% include 'htmx/table.html' %} 68 |
69 |
70 | {# /Objects table #} 71 | 72 |
73 |
74 | 75 |
76 | {# /Object list tab #} 77 | 78 | {% endblock content %} 79 | 80 | -------------------------------------------------------------------------------- /netbox_diode_plugin/templates/diode/client_credential_secret.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/_base.html' %} 2 | {% load i18n %} 3 | {% load helpers %} 4 | 5 | {% block title %}{% trans "Add Client Credential" %}{% endblock %} 6 | 7 | {% block tabs %} 8 | 13 | {% endblock tabs %} 14 | 15 | {% block content %} 16 |
17 | {% csrf_token %} 18 |
19 |
20 |
21 |
22 | 25 |
26 |
27 |

{{ object.client_name }}

28 |
29 |
30 | 31 |
32 |
33 | 36 |
37 |
38 |
39 | 40 | 43 |
44 |
45 |
46 | 47 |
48 |
49 | 52 |
53 |
54 |
55 | 56 | 59 |
60 | 61 | {% trans "You can only view your secret once. Be sure to save it before leaving." %} 62 | 63 |
64 |
65 | 66 | 73 |
74 |
75 |
76 | {% endblock %} -------------------------------------------------------------------------------- /netbox_diode_plugin/templates/diode/htmx/delete_form.html: -------------------------------------------------------------------------------- 1 | {% load form_helpers %} 2 | {% load i18n %} 3 | 4 |
5 | {% csrf_token %} 6 | 7 | 10 | 16 | 24 |
25 | -------------------------------------------------------------------------------- /netbox_diode_plugin/templates/diode/settings.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/_base.html' %} 2 | {% load buttons %} 3 | {% load helpers %} 4 | {% load i18n %} 5 | {% load render_table from django_tables2 %} 6 | {% block title %}{% trans "Settings" %}{% endblock %} 7 | 8 | {% block controls %} 9 | {% if not is_diode_target_overridden %} 10 |
11 | {% block control-buttons %} 12 | {% url 'plugins:netbox_diode_plugin:settings_edit' as edit_url %} 13 | {% include "buttons/edit.html" with url=edit_url %} 14 | {% endblock control-buttons %} 15 |
16 | {% endif %} 17 | {% endblock controls %} 18 | 19 | {% block content %} 20 |
21 |
22 |
23 | 24 | 25 | 26 | 27 | 28 |
{% trans "Diode target" %}{{ diode_target }}
29 |
30 |
31 |
32 | {% endblock content %} 33 | -------------------------------------------------------------------------------- /netbox_diode_plugin/templates/diode/settings_edit.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/_base.html' %} 2 | {% load i18n %} 3 | 4 | {% block title %} 5 | {% if object.pk %} 6 | {% trans "Editing" %} {{ object|meta:"verbose_name" }} {{ object }} 7 | {% else %} 8 | {% blocktrans trimmed with object_type=object|meta:"verbose_name" %} 9 | Add a new {{ object_type }} 10 | {% endblocktrans %} 11 | {% endif %} 12 | {% endblock title %} 13 | 14 | {% block content %} 15 |
16 | 17 |
18 | {% csrf_token %} 19 | 20 |
21 | {% block form %} 22 | {% include 'htmx/form.html' %} 23 | {% endblock form %} 24 |
25 | 26 |
27 | {% block buttons %} 28 | {% trans "Cancel" %} 29 | 32 | {% endblock buttons %} 33 |
34 |
35 |
36 | {% endblock content %} 37 | 38 | -------------------------------------------------------------------------------- /netbox_diode_plugin/tests/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2025 NetBox Labs, Inc. 3 | """Diode NetBox Plugin.""" 4 | -------------------------------------------------------------------------------- /netbox_diode_plugin/tests/test_api_generate_diff.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2025 NetBox Labs, Inc. 3 | """Diode NetBox Plugin - Tests.""" 4 | 5 | import logging 6 | from collections import defaultdict 7 | from types import SimpleNamespace 8 | from unittest import mock 9 | from uuid import uuid4 10 | 11 | from core.models import ObjectType 12 | from dcim.models import Manufacturer, RackType, Site 13 | from extras.models import CustomField 14 | from extras.models.customfields import CustomFieldTypeChoices 15 | from rest_framework import status 16 | from utilities.testing import APITestCase 17 | 18 | from netbox_diode_plugin.api.authentication import DiodeOAuth2Authentication 19 | from netbox_diode_plugin.plugin_config import get_diode_user 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | def _get_error(response, object_name, field): 24 | return response.json().get("errors", {}).get(object_name, {}).get(field, []) 25 | 26 | 27 | class GenerateDiffTestCase(APITestCase): 28 | """GenerateDiff test cases.""" 29 | 30 | def setUp(self): 31 | """Set up the test case.""" 32 | self.url = "/netbox/api/plugins/diode/generate-diff/" 33 | 34 | self.authorization_header = {"HTTP_AUTHORIZATION": "Bearer mocked_oauth_token"} 35 | self.diode_user = SimpleNamespace( 36 | user = get_diode_user(), 37 | token_scopes=["netbox:read", "netbox:write"], 38 | token_data={"scope": "netbox:read netbox:write"} 39 | ) 40 | 41 | self.introspect_patcher = mock.patch.object( 42 | DiodeOAuth2Authentication, 43 | '_introspect_token', 44 | return_value=self.diode_user 45 | ) 46 | self.introspect_patcher.start() 47 | 48 | self.object_type = ObjectType.objects.get_for_model(Site) 49 | 50 | self.uuid_field = CustomField.objects.create( 51 | name='myuuid', 52 | type=CustomFieldTypeChoices.TYPE_TEXT, 53 | required=False, 54 | unique=True, 55 | ) 56 | self.uuid_field.object_types.set([self.object_type]) 57 | self.uuid_field.save() 58 | 59 | self.json_field = CustomField.objects.create( 60 | name='some_json', 61 | type=CustomFieldTypeChoices.TYPE_JSON, 62 | required=False, 63 | unique=False, 64 | ) 65 | self.json_field.object_types.set([self.object_type]) 66 | self.json_field.save() 67 | 68 | self.site_uuid = str(uuid4()) 69 | self.site = Site.objects.create( 70 | name="Site Generate Diff 1", 71 | slug="site-generate-diff-1", 72 | facility="Alpha", 73 | description="First test site", 74 | physical_address="123 Fake St Lincoln NE 68588", 75 | shipping_address="123 Fake St Lincoln NE 68588", 76 | comments="Lorem ipsum etcetera", 77 | ) 78 | self.site.custom_field_data[self.uuid_field.name] = self.site_uuid 79 | self.site.custom_field_data[self.json_field.name] = { 80 | "some_key": "some_value", 81 | } 82 | self.site.save() 83 | 84 | self.manufacturer = Manufacturer.objects.create( 85 | name="Manufacturer 1", 86 | ) 87 | self.manufacturer.save() 88 | self.rack_type = RackType.objects.create( 89 | model="Rack Type 1", 90 | slug="rack-type-1", 91 | manufacturer=self.manufacturer, 92 | ) 93 | self.rack_type.save() 94 | 95 | def tearDown(self): 96 | """Clean up after tests.""" 97 | self.introspect_patcher.stop() 98 | super().tearDown() 99 | 100 | def test_generate_diff_create_site(self): 101 | """Test generate diff create site.""" 102 | payload = { 103 | "timestamp": 1, 104 | "object_type": "dcim.site", 105 | "entity": { 106 | "site": { 107 | "name": "A New Site", 108 | "slug": "a-new-site", 109 | }, 110 | } 111 | } 112 | 113 | response = self.send_request(payload) 114 | self.assertEqual(response.status_code, status.HTTP_200_OK) 115 | cs = response.json().get("change_set", {}) 116 | self.assertIsNotNone(cs.get("id")) 117 | changes = cs.get("changes", []) 118 | self.assertEqual(len(changes), 1) 119 | change = changes[0] 120 | self.assertEqual(change.get("object_type"), "dcim.site") 121 | self.assertEqual(change.get("change_type"), "create") 122 | self.assertEqual(change.get("object_id"), None) 123 | self.assertIsNotNone(change.get("ref_id")) 124 | 125 | data = change.get("data", {}) 126 | self.assertEqual(data.get("name"), "A New Site") 127 | self.assertEqual(data.get("slug"), "a-new-site") 128 | 129 | def test_generate_diff_create_site_with_custom_field(self): 130 | """Test generate diff create site with custom field.""" 131 | payload = { 132 | "timestamp": 1, 133 | "object_type": "dcim.site", 134 | "entity": { 135 | "site": { 136 | "name": "A New Site", 137 | "slug": "a-new-site", 138 | "custom_fields": { 139 | "some_json": { 140 | "json": '{"some_key": 1234567890}', 141 | }, 142 | }, 143 | }, 144 | } 145 | } 146 | 147 | response = self.send_request(payload) 148 | self.assertEqual(response.status_code, status.HTTP_200_OK) 149 | cs = response.json().get("change_set", {}) 150 | self.assertIsNotNone(cs.get("id")) 151 | changes = cs.get("changes", []) 152 | self.assertEqual(len(changes), 1) 153 | change = changes[0] 154 | self.assertEqual(change.get("object_type"), "dcim.site") 155 | self.assertEqual(change.get("change_type"), "create") 156 | self.assertEqual(change.get("object_id"), None) 157 | self.assertIsNotNone(change.get("ref_id")) 158 | 159 | data = change.get("data", {}) 160 | self.assertEqual(data.get("name"), "A New Site") 161 | self.assertEqual(data.get("slug"), "a-new-site") 162 | self.assertEqual(data.get("custom_fields", {}).get("some_json", {}).get("some_key"), 1234567890) 163 | 164 | def test_generate_diff_update_site(self): 165 | """Test generate diff update site.""" 166 | """Test generate diff create site.""" 167 | payload = { 168 | "timestamp": 1, 169 | "object_type": "dcim.site", 170 | "entity": { 171 | "site": { 172 | "name": "Site Generate Diff 1", 173 | "slug": "site-generate-diff-1", 174 | "comments": "An updated comment", 175 | }, 176 | } 177 | } 178 | 179 | response = self.send_request(payload) 180 | self.assertEqual(response.status_code, status.HTTP_200_OK) 181 | cs = response.json().get("change_set", {}) 182 | self.assertIsNotNone(cs.get("id")) 183 | changes = cs.get("changes", []) 184 | self.assertEqual(len(changes), 1) 185 | change = changes[0] 186 | self.assertEqual(change.get("object_type"), "dcim.site") 187 | self.assertEqual(change.get("change_type"), "update") 188 | self.assertEqual(change.get("object_id"), self.site.id) 189 | self.assertEqual(change.get("ref_id"), None) 190 | self.assertEqual(change.get("data").get("name"), "Site Generate Diff 1") 191 | 192 | data = change.get("data", {}) 193 | self.assertEqual(data.get("name"), "Site Generate Diff 1") 194 | self.assertEqual(data.get("slug"), "site-generate-diff-1") 195 | self.assertEqual(data.get("comments"), "An updated comment") 196 | 197 | def test_match_site_by_custom_field(self): 198 | """Test match site by custom field.""" 199 | payload = { 200 | "timestamp": 1, 201 | "object_type": "dcim.site", 202 | "entity": { 203 | "site": { 204 | # here name and slug are not present in the payload 205 | # but we expect to match the existing site by the 206 | # unique custom field myuuid 207 | "comments": "A custom comment", 208 | "custom_fields": { 209 | "myuuid": { 210 | "text": self.site_uuid, 211 | }, 212 | }, 213 | }, 214 | } 215 | } 216 | 217 | response = self.send_request(payload) 218 | self.assertEqual(response.status_code, status.HTTP_200_OK) 219 | cs = response.json().get("change_set", {}) 220 | self.assertIsNotNone(cs.get("id")) 221 | changes = cs.get("changes", []) 222 | self.assertEqual(len(changes), 1) 223 | change = changes[0] 224 | self.assertEqual(change.get("object_type"), "dcim.site") 225 | self.assertEqual(change.get("change_type"), "update") 226 | self.assertEqual(change.get("object_id"), self.site.id) 227 | self.assertEqual(change.get("ref_id"), None) 228 | 229 | data = change.get("data", {}) 230 | self.assertEqual(data.get("comments"), "A custom comment") 231 | self.assertEqual(data.get("custom_fields", {}).get("myuuid"), self.site_uuid) 232 | 233 | before = change.get("before", {}) 234 | self.assertEqual(before.get("name"), "Site Generate Diff 1") 235 | self.assertEqual(before.get("slug"), "site-generate-diff-1") 236 | 237 | def test_generate_diff_update_rack_type_autoslug(self): 238 | """Test generate diff update rack type autoslug.""" 239 | payload = { 240 | "timestamp": 1, 241 | "object_type": "dcim.racktype", 242 | "entity": { 243 | "rack_type": { 244 | "model": "Rack Type 1", 245 | "form_factor": "wall-frame", 246 | }, 247 | } 248 | } 249 | 250 | response = self.send_request(payload) 251 | self.assertEqual(response.status_code, status.HTTP_200_OK) 252 | cs = response.json().get("change_set", {}) 253 | self.assertIsNotNone(cs.get("id")) 254 | changes = cs.get("changes", []) 255 | self.assertEqual(len(changes), 1) 256 | change = changes[0] 257 | self.assertEqual(change.get("object_type"), "dcim.racktype") 258 | self.assertEqual(change.get("change_type"), "update") 259 | self.assertEqual(change.get("object_id"), self.rack_type.id) 260 | self.assertEqual(change.get("ref_id"), None) 261 | 262 | data = change.get("data", {}) 263 | self.assertEqual(data.get("model"), "Rack Type 1") 264 | self.assertEqual(data.get("slug"), None) # slug is not set, use prior slug 265 | self.assertEqual(data.get("form_factor"), "wall-frame") 266 | 267 | before = change.get("before", {}) 268 | self.assertEqual(before.get("model"), "Rack Type 1") 269 | # correct slug is present in before data 270 | self.assertEqual(before.get("slug"), "rack-type-1") 271 | 272 | def test_generate_diff_update_rack_type_camel_case(self): 273 | """Test generate diff update rack type with came cased protoJSON.""" 274 | payload = { 275 | "timestamp": 1, 276 | "object_type": "dcim.racktype", 277 | "entity": { 278 | "rackType": { 279 | "slug": "rack-type-1", 280 | "model": "Rack Type 1", 281 | "formFactor": "wall-frame", 282 | }, 283 | } 284 | } 285 | 286 | response = self.send_request(payload) 287 | self.assertEqual(response.status_code, status.HTTP_200_OK) 288 | cs = response.json().get("change_set", {}) 289 | self.assertIsNotNone(cs.get("id")) 290 | changes = cs.get("changes", []) 291 | self.assertEqual(len(changes), 1) 292 | change = changes[0] 293 | self.assertEqual(change.get("object_type"), "dcim.racktype") 294 | self.assertEqual(change.get("change_type"), "update") 295 | self.assertEqual(change.get("object_id"), self.rack_type.id) 296 | self.assertEqual(change.get("ref_id"), None) 297 | 298 | data = change.get("data", {}) 299 | self.assertEqual(data.get("model"), "Rack Type 1") 300 | self.assertEqual(data.get("form_factor"), "wall-frame") 301 | 302 | before = change.get("before", {}) 303 | self.assertEqual(before.get("model"), "Rack Type 1") 304 | 305 | def test_merge_states_failed(self): 306 | """Test merge states failed.""" 307 | payload = { 308 | "timestamp": 1, 309 | "object_type": "ipam.vrf", 310 | "entity": { 311 | "vrf": { 312 | "name": "Customer-A-VRF", 313 | "rd": "65000:100", 314 | "tenant": {"name": "Tenant 1"}, 315 | "enforce_unique": True, 316 | "description": "Isolated routing domain for Customer A", 317 | "comments": "Used for customer's private network services", 318 | "tags": [ 319 | { 320 | "name": "Tag 1" 321 | }, 322 | { 323 | "name": "Tag 2" 324 | } 325 | ], 326 | "import_targets": [ 327 | { 328 | "name": "65000:100", 329 | "description": "Primary import route target" 330 | }, 331 | { 332 | "name": "65000:101", 333 | "description": "Backup import route target" 334 | } 335 | ], 336 | "export_targets": [ 337 | { 338 | "name": "65000:100", 339 | "description": "Primary export route target" 340 | } 341 | ] 342 | } 343 | } 344 | } 345 | 346 | response = self.send_request(payload, status.HTTP_400_BAD_REQUEST) 347 | logger.error(response.json()) 348 | errs = _get_error(response, "ipam.vrf", "__all__") 349 | self.assertEqual(len(errs), 1) 350 | err = errs[0] 351 | self.assertTrue(err.startswith("Conflicting values for 'description' merging duplicate ipam.routetarget")) 352 | 353 | def test_vlangroup_error(self): 354 | """Test vlangroup error.""" 355 | payload = { 356 | "timestamp": 1, 357 | "object_type": "ipam.vlangroup", 358 | "entity": { 359 | "vlan_group": { 360 | "name": "Data Center Core", 361 | "slug": "dc-core", 362 | "scope_site": { 363 | "name": "Data Center West", 364 | "slug": "dc-west", 365 | "status": "active" 366 | }, 367 | "description": "Core network VLANs for data center infrastructure", 368 | "tags": [ 369 | { 370 | "name": "Tag 1" 371 | }, 372 | { 373 | "name": "Tag 2" 374 | } 375 | ] 376 | } 377 | } 378 | } 379 | _ = self.send_request(payload) 380 | 381 | def test_generate_diff_dedupe_different_object_types(self): 382 | """Test generate diff dedupe different object types with same values.""" 383 | payload = { 384 | "timestamp": 1, 385 | "object_type": "dcim.device", 386 | "entity": { 387 | "device": { 388 | "name": "Cat8000V", 389 | "role": {"name": "undefined"}, 390 | "site": {"name": "undefined"}, 391 | "serial": "9OBXJHNNU5V", 392 | "status": "active", 393 | "platform": {"name": "ios", "manufacturer": {"name": "undefined"}}, 394 | "device_type": {"model": "C8000V", "manufacturer": {"name": "undefined"}} 395 | }, 396 | }, 397 | } 398 | response = self.send_request(payload) 399 | self.assertEqual(response.status_code, status.HTTP_200_OK) 400 | cs = response.json().get("change_set", {}) 401 | self.assertIsNotNone(cs.get("id")) 402 | changes = cs.get("changes", []) 403 | self.assertEqual(len(changes), 6) 404 | by_object_type = defaultdict(int) 405 | for change in changes: 406 | by_object_type[change.get("object_type")] += 1 407 | 408 | self.assertEqual(by_object_type["dcim.device"], 1) 409 | self.assertEqual(by_object_type["dcim.manufacturer"], 1) 410 | self.assertEqual(by_object_type["dcim.platform"], 1) 411 | self.assertEqual(by_object_type["dcim.devicetype"], 1) 412 | self.assertEqual(by_object_type["dcim.site"], 1) 413 | self.assertEqual(by_object_type["dcim.devicerole"], 1) 414 | 415 | def send_request(self, payload, status_code=status.HTTP_200_OK): 416 | """Post the payload to the url and return the response.""" 417 | response = self.client.post( 418 | self.url, data=payload, format="json", **self.authorization_header 419 | ) 420 | self.assertEqual(response.status_code, status_code) 421 | return response 422 | -------------------------------------------------------------------------------- /netbox_diode_plugin/tests/test_authentication.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2025 NetBox Labs, Inc. 3 | """Diode NetBox Plugin - Authentication Tests.""" 4 | 5 | from types import SimpleNamespace 6 | from unittest import mock 7 | 8 | from django.core.cache import cache 9 | from django.test import TestCase 10 | from rest_framework.exceptions import AuthenticationFailed 11 | from rest_framework.test import APIRequestFactory 12 | 13 | from netbox_diode_plugin.api.authentication import DiodeOAuth2Authentication 14 | from netbox_diode_plugin.plugin_config import get_diode_user 15 | 16 | 17 | class DiodeOAuth2AuthenticationTestCase(TestCase): 18 | """Test cases for DiodeOAuth2Authentication.""" 19 | 20 | def setUp(self): 21 | """Set up test case.""" 22 | self.auth = DiodeOAuth2Authentication() 23 | self.factory = APIRequestFactory() 24 | self.diode_user = SimpleNamespace( 25 | user = get_diode_user(), 26 | token_scopes=["netbox:read", "netbox:write"], 27 | token_data={"scope": "netbox:read netbox:write"} 28 | ) 29 | self.valid_token = "valid_oauth_token" 30 | self.invalid_token = "invalid_oauth_token" 31 | self.token_without_scope = "token_without_scope" 32 | self.token_with_scope = "token_with_scope" 33 | 34 | # Mock the cache 35 | self.cache_patcher = mock.patch.object(cache, 'get') 36 | self.cache_get_mock = self.cache_patcher.start() 37 | self.cache_set_patcher = mock.patch.object(cache, 'set') 38 | self.cache_set_mock = self.cache_set_patcher.start() 39 | 40 | # Mock requests.post for token introspection 41 | self.requests_patcher = mock.patch('requests.post') 42 | self.requests_mock = self.requests_patcher.start() 43 | self.requests_mock.return_value.raise_for_status = mock.Mock() 44 | 45 | # Mock get_diode_auth_introspect_url 46 | self.introspect_url_patcher = mock.patch( 47 | 'netbox_diode_plugin.plugin_config.get_diode_auth_introspect_url', 48 | return_value='http://test-introspect-url' 49 | ) 50 | self.introspect_url_patcher.start() 51 | 52 | def tearDown(self): 53 | """Clean up after tests.""" 54 | self.cache_patcher.stop() 55 | self.cache_set_patcher.stop() 56 | self.requests_patcher.stop() 57 | self.introspect_url_patcher.stop() 58 | 59 | def test_authenticate_no_auth_header(self): 60 | """Test authentication with no Authorization header.""" 61 | request = self.factory.get('/') 62 | result = self.auth.authenticate(request) 63 | self.assertIsNone(result) 64 | 65 | def test_authenticate_invalid_auth_header_format(self): 66 | """Test authentication with invalid Authorization header format.""" 67 | request = self.factory.get('/', HTTP_AUTHORIZATION='InvalidFormat') 68 | result = self.auth.authenticate(request) 69 | self.assertIsNone(result) 70 | 71 | def test_authenticate_cached_token(self): 72 | """Test authentication with cached token.""" 73 | self.cache_get_mock.return_value = self.diode_user 74 | request = self.factory.get('/', HTTP_AUTHORIZATION=f'Bearer {self.valid_token}') 75 | 76 | user, _ = self.auth.authenticate(request) 77 | self.assertEqual(user, self.diode_user.user) 78 | self.cache_get_mock.assert_called_once() 79 | 80 | def test_authenticate_invalid_token(self): 81 | """Test authentication with invalid token.""" 82 | self.cache_get_mock.return_value = None 83 | self.requests_mock.return_value.json.return_value = {'active': False} 84 | 85 | request = self.factory.get('/', HTTP_AUTHORIZATION=f'Bearer {self.invalid_token}') 86 | 87 | with self.assertRaises(AuthenticationFailed): 88 | self.auth.authenticate(request) 89 | 90 | def test_authenticate_token_with_required_scope(self): 91 | """Test authentication with token having required scope.""" 92 | self.cache_get_mock.return_value = None 93 | self.requests_mock.return_value.json.return_value = { 94 | 'active': True, 95 | 'scope': 'netbox:read netbox:write', 96 | 'exp': 1000, 97 | 'iat': 500 98 | } 99 | 100 | request = self.factory.get('/', HTTP_AUTHORIZATION=f'Bearer {self.token_with_scope}') 101 | 102 | user, _ = self.auth.authenticate(request) 103 | self.assertEqual(user, self.diode_user.user) 104 | self.cache_set_mock.assert_called_once() 105 | 106 | def test_authenticate_token_introspection_failure(self): 107 | """Test authentication when token introspection fails.""" 108 | self.cache_get_mock.return_value = None 109 | self.requests_mock.side_effect = Exception("Introspection failed") 110 | 111 | request = self.factory.get('/', HTTP_AUTHORIZATION=f'Bearer {self.valid_token}') 112 | 113 | with self.assertRaises(AuthenticationFailed): 114 | self.auth.authenticate(request) 115 | 116 | def test_authenticate_token_with_default_expiry(self): 117 | """Test authentication with token having no expiry information.""" 118 | self.cache_get_mock.return_value = None 119 | self.requests_mock.return_value.json.return_value = { 120 | 'active': True, 121 | 'scope': 'netbox:read netbox:write' 122 | } 123 | 124 | request = self.factory.get('/', HTTP_AUTHORIZATION=f'Bearer {self.token_with_scope}') 125 | 126 | user, _ = self.auth.authenticate(request) 127 | self.assertEqual(user, self.diode_user.user) 128 | 129 | self.cache_set_mock.assert_called_once() 130 | 131 | # Get the actual call arguments 132 | call_args = self.cache_set_mock.call_args 133 | if not call_args: 134 | self.fail("Cache set was not called with any arguments") 135 | 136 | # The cache key should start with 'diode:oauth2:introspect:' 137 | cache_key = call_args.args[0] 138 | self.assertTrue(cache_key.startswith('diode:oauth2:introspect:')) 139 | 140 | # The cached value should be the diode user 141 | self.assertEqual(call_args.args[1].user, self.diode_user.user) 142 | self.assertEqual(call_args.args[1].token_scopes, self.diode_user.token_scopes) 143 | 144 | # The timeout should be 300 (default) 145 | self.assertEqual(call_args.kwargs['timeout'], 300) 146 | -------------------------------------------------------------------------------- /netbox_diode_plugin/tests/test_diode_clients.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2025 NetBox Labs, Inc. 3 | """Diode NetBox Plugin - Diode Clients API Tests.""" 4 | 5 | from unittest import mock 6 | 7 | from django.test import TestCase 8 | 9 | from netbox_diode_plugin.diode.clients import ClientAPI, ClientAPIError 10 | 11 | 12 | class DiodeClientsTestCase(TestCase): 13 | """Test cases for Diode Clients API.""" 14 | 15 | def test_create_client(self): 16 | """Test creating a client.""" 17 | with mock.patch('requests.post') as mock_post: 18 | client = ClientAPI( 19 | base_url="http://test-diode-url", 20 | client_id="test-client-id", 21 | client_secret="test-client-secret" 22 | ) 23 | client._client_auth_token = "test-client-auth-token" 24 | 25 | mock_post.return_value.status_code = 201 26 | mock_post.return_value.json.return_value = { 27 | "client_id": "test-client-id", 28 | "client_secret": "test-client-secret", 29 | "client_name": "test-client", 30 | "scope": "test-scope" 31 | } 32 | 33 | created = client.create_client( 34 | name="test-client", 35 | scope="test-scope" 36 | ) 37 | 38 | self.assertEqual(created, { 39 | "client_id": "test-client-id", 40 | "client_secret": "test-client-secret", 41 | "client_name": "test-client", 42 | "scope": "test-scope" 43 | }) 44 | 45 | mock_post.assert_called_once_with( 46 | "http://test-diode-url/clients", 47 | headers={ 48 | "Authorization": "Bearer test-client-auth-token" 49 | }, 50 | json={ 51 | "client_name": "test-client", 52 | "scope": "test-scope" 53 | } 54 | ) 55 | 56 | def test_list_clients(self): 57 | """Test listing clients.""" 58 | with mock.patch('requests.get') as mock_get: 59 | client = ClientAPI( 60 | base_url="http://test-diode-url", 61 | client_id="test-client-id", 62 | client_secret="test-client-secret" 63 | ) 64 | client._client_auth_token = "test-client-auth-token" 65 | 66 | mock_get.return_value.status_code = 200 67 | mock_get.return_value.json.return_value = { 68 | "data": [ 69 | { 70 | "client_id": "test-client-id", 71 | "client_name": "test-client", 72 | "scope": "test-scope" 73 | } 74 | ], 75 | "next_page_token": "test-next-page-token", 76 | "prev_page_token": "test-prev-page-token" 77 | } 78 | 79 | result = client.list_clients(page_size=100) 80 | 81 | self.assertEqual(result["data"], [ 82 | { 83 | "client_id": "test-client-id", 84 | "client_name": "test-client", 85 | "scope": "test-scope" 86 | } 87 | ]) 88 | 89 | self.assertEqual(result["next_page_token"], "test-next-page-token") 90 | self.assertEqual(result["prev_page_token"], "test-prev-page-token") 91 | 92 | mock_get.assert_called_once_with( 93 | "http://test-diode-url/clients", 94 | headers={ 95 | "Authorization": "Bearer test-client-auth-token" 96 | }, 97 | params={ 98 | "page_size": 100, 99 | } 100 | ) 101 | 102 | def test_get_client(self): 103 | """Test getting a client.""" 104 | with mock.patch('requests.get') as mock_get: 105 | client = ClientAPI( 106 | base_url="http://test-diode-url", 107 | client_id="test-client-id", 108 | client_secret="test-client-secret" 109 | ) 110 | client._client_auth_token = "test-client-auth-token" 111 | 112 | mock_get.return_value.status_code = 200 113 | mock_get.return_value.json.return_value = { 114 | "client_id": "test-client-id", 115 | "client_name": "test-client", 116 | "scope": "test-scope" 117 | } 118 | 119 | result = client.get_client("test-client-id") 120 | 121 | self.assertEqual(result, { 122 | "client_id": "test-client-id", 123 | "client_name": "test-client", 124 | "scope": "test-scope" 125 | }) 126 | 127 | mock_get.assert_called_once_with( 128 | "http://test-diode-url/clients/test-client-id", 129 | headers={ 130 | "Authorization": "Bearer test-client-auth-token" 131 | } 132 | ) 133 | 134 | def test_get_client_raises_error_on_bad_id(self): 135 | """Test getting a client raises an error on bad ID.""" 136 | client = ClientAPI( 137 | base_url="http://test-diode-url", 138 | client_id="test-client-id", 139 | client_secret="test-client-secret" 140 | ) 141 | with self.assertRaises(ValueError): 142 | client.get_client("../bad/../client/id") 143 | 144 | def test_delete_client(self): 145 | """Test deleting a client.""" 146 | with mock.patch('requests.delete') as mock_delete: 147 | client = ClientAPI( 148 | base_url="http://test-diode-url", 149 | client_id="test-client-id", 150 | client_secret="test-client-secret" 151 | ) 152 | client._client_auth_token = "test-client-auth-token" 153 | 154 | mock_delete.return_value.status_code = 204 155 | mock_delete.return_value.raise_for_status = mock.Mock() 156 | 157 | client.delete_client("test-client-id") 158 | 159 | mock_delete.assert_called_once_with( 160 | "http://test-diode-url/clients/test-client-id", 161 | headers={ 162 | "Authorization": "Bearer test-client-auth-token" 163 | } 164 | ) 165 | 166 | def test_delete_client_raises_error_on_bad_id(self): 167 | """Test deleting a client raises an error on bad ID.""" 168 | client = ClientAPI( 169 | base_url="http://test-diode-url", 170 | client_id="test-client-id", 171 | client_secret="test-client-secret" 172 | ) 173 | with self.assertRaises(ValueError): 174 | client.delete_client("../bad/../client/id") 175 | 176 | def test_authentication_retries(self): 177 | """Test authentication retries.""" 178 | with mock.patch('requests.post') as mock_post: 179 | client = ClientAPI( 180 | base_url="http://test-diode-url", 181 | client_id="test-client-id", 182 | client_secret="test-client-secret" 183 | ) 184 | client._client_auth_token = "test-client-auth-token" 185 | 186 | mock_post.side_effect = [ 187 | ClientAPIError("Failed to create client", 401), 188 | mock.Mock(status_code=200, json=lambda: {"access_token": "new-access-token"}), 189 | mock.Mock(status_code=201, json=lambda: { 190 | "client_id": "test-client-id", 191 | "client_secret": "test-client-secret", 192 | "client_name": "test-client", 193 | "scope": "diode:read diode:write" 194 | }), 195 | ] 196 | 197 | result = client.create_client("test-client", "diode:read diode:write") 198 | self.assertEqual(result, { 199 | "client_id": "test-client-id", 200 | "client_secret": "test-client-secret", 201 | "client_name": "test-client", 202 | "scope": "diode:read diode:write" 203 | }) 204 | 205 | self.assertEqual(mock_post.call_count, 3) 206 | 207 | mock_post.assert_has_calls([ 208 | mock.call("http://test-diode-url/clients", 209 | headers={ 210 | "Authorization": "Bearer test-client-auth-token" 211 | }, 212 | json={ 213 | "client_name": "test-client", 214 | "scope": "diode:read diode:write", 215 | } 216 | ), 217 | mock.call("http://test-diode-url/token", 218 | data='grant_type=client_credentials&client_id=test-client-id&client_secret=test-client-secret&scope=diode%3Aread+diode%3Awrite', 219 | headers={'Content-Type': 'application/x-www-form-urlencoded'} 220 | ), 221 | mock.call("http://test-diode-url/clients", 222 | headers={ 223 | "Authorization": "Bearer new-access-token" 224 | }, 225 | json={ 226 | "client_name": "test-client", 227 | "scope": "diode:read diode:write", 228 | } 229 | ), 230 | ]) 231 | 232 | 233 | -------------------------------------------------------------------------------- /netbox_diode_plugin/tests/test_forms.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2025 NetBox Labs, Inc. 3 | """Diode NetBox Plugin - Tests.""" 4 | from unittest import mock 5 | 6 | from django.test import TestCase 7 | 8 | from netbox_diode_plugin.forms import SettingsForm 9 | from netbox_diode_plugin.models import Setting 10 | 11 | 12 | class SettingsFormTestCase(TestCase): 13 | """Test case for the SettingsForm.""" 14 | 15 | def setUp(self): 16 | """Set up the test case.""" 17 | self.setting = Setting.objects.create( 18 | diode_target="grpc://localhost:8080/diode" 19 | ) 20 | 21 | def test_form_initialization_with_override_allowed(self): 22 | """Test form initialization when override is allowed.""" 23 | with mock.patch( 24 | "netbox_diode_plugin.forms.get_plugin_config" 25 | ) as mock_get_plugin_config: 26 | mock_get_plugin_config.return_value = None 27 | form = SettingsForm(instance=self.setting) 28 | mock_get_plugin_config.assert_called_with( 29 | "netbox_diode_plugin", "diode_target_override" 30 | ) 31 | self.assertFalse(form.fields["diode_target"].disabled) 32 | self.assertNotIn( 33 | "This field is not allowed to be modified.", 34 | form.fields["diode_target"].help_text, 35 | ) 36 | 37 | def test_form_initialization_with_diode_target_override(self): 38 | """Test form initialization when override is disallowed.""" 39 | with mock.patch( 40 | "netbox_diode_plugin.forms.get_plugin_config" 41 | ) as mock_get_plugin_config: 42 | mock_get_plugin_config.return_value = "grpc://localhost:8080/diode" 43 | form = SettingsForm(instance=self.setting) 44 | mock_get_plugin_config.assert_called_with( 45 | "netbox_diode_plugin", "diode_target_override" 46 | ) 47 | self.assertTrue(form.fields["diode_target"].disabled) 48 | self.assertEqual( 49 | "This field is not allowed to be modified.", 50 | form.fields["diode_target"].help_text, 51 | ) 52 | -------------------------------------------------------------------------------- /netbox_diode_plugin/tests/test_models.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2025 NetBox Labs, Inc. 3 | """Diode NetBox Plugin - Tests.""" 4 | from django.core.exceptions import ValidationError 5 | from django.test import TestCase 6 | 7 | from netbox_diode_plugin.models import Setting 8 | 9 | 10 | class SettingModelTestCase(TestCase): 11 | """Test case for the models.""" 12 | 13 | def test_validators(self): 14 | """Check Setting model field validators are functional.""" 15 | setting = Setting(diode_target="http://localhost:8080") 16 | 17 | with self.assertRaises(ValidationError): 18 | setting.clean_fields() 19 | 20 | 21 | def test_str(self): 22 | """Check Setting model string representation.""" 23 | setting = Setting(diode_target="http://localhost:8080") 24 | self.assertEqual(str(setting), "") 25 | 26 | 27 | def test_absolute_url(self): 28 | """Check Setting model absolute URL.""" 29 | setting = Setting() 30 | self.assertEqual(setting.get_absolute_url(), "/netbox/plugins/diode/settings/") 31 | -------------------------------------------------------------------------------- /netbox_diode_plugin/tests/test_plugin_config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2025 NetBox Labs, Inc. 3 | """Diode NetBox Plugin - Tests.""" 4 | 5 | from django.contrib.auth import get_user_model 6 | from django.test import TestCase 7 | 8 | from netbox_diode_plugin.plugin_config import get_diode_auth_introspect_url, get_diode_user 9 | 10 | User = get_user_model() 11 | 12 | 13 | class PluginConfigTestCase(TestCase): 14 | """Test case for plugin config helpers.""" 15 | 16 | def test_get_diode_auth_introspect_url(self): 17 | """Test get_diode_auth_introspect_url function.""" 18 | expected = "http://localhost:8080/diode/auth/introspect" 19 | self.assertEqual(get_diode_auth_introspect_url(), expected) 20 | 21 | def test_get_diode_user(self): 22 | """Test get_diode_user function.""" 23 | diode_user = get_diode_user() 24 | expected_diode_user = User.objects.get(username="diode") 25 | self.assertEqual(diode_user, expected_diode_user) 26 | 27 | -------------------------------------------------------------------------------- /netbox_diode_plugin/tests/test_updates.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2024 NetBox Labs Inc 3 | """Diode NetBox Plugin - Tests.""" 4 | 5 | import inspect 6 | import json 7 | import logging 8 | import os 9 | from types import SimpleNamespace 10 | from unittest import mock 11 | 12 | from django.db.models import QuerySet 13 | from rest_framework import status 14 | from utilities.testing import APITestCase 15 | 16 | from netbox_diode_plugin.api.authentication import DiodeOAuth2Authentication 17 | from netbox_diode_plugin.api.common import harmonize_formats 18 | from netbox_diode_plugin.api.plugin_utils import get_object_type_model 19 | from netbox_diode_plugin.plugin_config import get_diode_user 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | def _harmonize_formats(data): 25 | data = harmonize_formats(data) 26 | return _tuples_to_lists(data) 27 | 28 | def _tuples_to_lists(data): 29 | if isinstance(data, (tuple, list)): 30 | return [_tuples_to_lists(d) for d in data] 31 | if isinstance(data, dict): 32 | return {k: _tuples_to_lists(v) for k, v in data.items()} 33 | return data 34 | 35 | def load_test_cases(cls): 36 | """Class decorator to load test cases and create test methods.""" 37 | logger.debug("Loading apply updates test cases") 38 | current_dir = os.path.dirname(os.path.abspath(__file__)) 39 | test_data_path = os.path.join(current_dir, "test_updates_cases.json") 40 | 41 | if not os.path.exists(test_data_path): 42 | raise FileNotFoundError(f"Test data file not found at {test_data_path}") 43 | 44 | def _create_and_update_test_case(case): 45 | object_type = case["object_type"] 46 | 47 | def test_func(self): 48 | model = get_object_type_model(object_type) 49 | 50 | payload = { 51 | "timestamp": 1, 52 | "object_type": object_type, 53 | "entity": case["create"], 54 | } 55 | res = self.send_request(self.diff_url, payload) 56 | self.assertEqual(res.status_code, status.HTTP_200_OK) 57 | diff = res.json().get("change_set", {}) 58 | res = self.client.post( 59 | self.apply_url, data=diff, format="json", **self.authorization_header 60 | ) 61 | self.assertEqual(res.status_code, status.HTTP_200_OK) 62 | # lookup the object and check fields 63 | obj = model.objects.get(**case["lookup"]) 64 | self._check_expect(obj, case["create_expect"]) 65 | 66 | # resending the same payload should not change anything 67 | payload = { 68 | "timestamp": 2, 69 | "object_type": object_type, 70 | "entity": case["create"], 71 | } 72 | res = self.send_request(self.diff_url, payload) 73 | self.assertEqual(res.status_code, status.HTTP_200_OK) 74 | 75 | change_set = res.json().get("change_set", {}) 76 | if change_set.get("changes", []) != []: 77 | logger.error(f"Unexpected change set {json.dumps(change_set, indent=4)}") 78 | 79 | self.assertEqual(res.json().get("change_set", {}).get("changes", []), []) 80 | 81 | # updating the object 82 | payload = { 83 | "timestamp": 3, 84 | "object_type": object_type, 85 | "entity": case["update"], 86 | } 87 | res = self.send_request(self.diff_url, payload) 88 | self.assertEqual(res.status_code, status.HTTP_200_OK) 89 | 90 | diff = res.json().get("change_set", {}) 91 | res = self.client.post( 92 | self.apply_url, data=diff, format="json", **self.authorization_header 93 | ) 94 | self.assertEqual(res.status_code, status.HTTP_200_OK) 95 | obj = model.objects.get(**case["lookup"]) 96 | self._check_expect(obj, case["update_expect"]) 97 | 98 | test_func.__name__ = f"test_updates_{case['name']}" 99 | return test_func 100 | 101 | with open(test_data_path) as f: 102 | test_cases = json.load(f) 103 | for case in test_cases: 104 | t = _create_and_update_test_case(case) 105 | logger.debug(f"Creating test case {t.__name__}") 106 | setattr(cls, t.__name__, t) 107 | 108 | return cls 109 | 110 | @load_test_cases 111 | class ApplyUpdatesTestCase(APITestCase): 112 | """diff/create/update test cases.""" 113 | 114 | @classmethod 115 | def setUpClass(cls): 116 | """Set up the test cases.""" 117 | super().setUpClass() 118 | 119 | def setUp(self): 120 | """Set up the test case.""" 121 | self.diff_url = "/netbox/api/plugins/diode/generate-diff/" 122 | self.apply_url = "/netbox/api/plugins/diode/apply-change-set/" 123 | self.authorization_header = {"HTTP_AUTHORIZATION": "Bearer mocked_oauth_token"} 124 | self.diode_user = SimpleNamespace( 125 | user = get_diode_user(), 126 | token_scopes=["netbox:read", "netbox:write"], 127 | token_data={"scope": "netbox:read netbox:write"} 128 | ) 129 | 130 | self.introspect_patcher = mock.patch.object( 131 | DiodeOAuth2Authentication, 132 | '_introspect_token', 133 | return_value=self.diode_user 134 | ) 135 | self.introspect_patcher.start() 136 | 137 | def tearDown(self): 138 | """Clean up after tests.""" 139 | self.introspect_patcher.stop() 140 | super().tearDown() 141 | 142 | def _follow_path(self, obj, path): 143 | cur = obj 144 | for i, p in enumerate(path): 145 | if p.isdigit(): 146 | p = int(p) 147 | cur = cur[p] 148 | else: 149 | cur = getattr(cur, p) 150 | if i != len(path) - 1: 151 | self.assertIsNotNone(cur) 152 | if callable(cur): 153 | try: 154 | signature = inspect.signature(cur) 155 | if len(signature.parameters) == 0: 156 | cur = cur() 157 | except ValueError: 158 | pass 159 | if isinstance(cur, QuerySet): 160 | cur = list(cur) 161 | return cur 162 | 163 | def _check_set_by(self, obj, path, value): 164 | key = path[-1][len("__by_"):] 165 | path = path[:-1] 166 | cur = self._follow_path(obj, path) 167 | 168 | if isinstance(value, (list, tuple)): 169 | vals = set(value) 170 | else: 171 | vals = {value} 172 | 173 | cvals = {_harmonize_formats(getattr(c, key)) for c in cur} 174 | self.assertEqual(cvals, vals) 175 | 176 | def _check_equals(self, obj, path, value): 177 | cur = self._follow_path(obj, path) 178 | cur = _harmonize_formats(cur) 179 | self.assertEqual(cur, value) 180 | 181 | def _check_expect(self, obj, expect): 182 | for field, value in expect.items(): 183 | path = field.strip().split(".") 184 | if path[-1].startswith("__by_"): 185 | self._check_set_by(obj, path, value) 186 | else: 187 | self._check_equals(obj, path, value) 188 | 189 | def send_request(self, url, payload, status_code=status.HTTP_200_OK): 190 | """Post the payload to the url and return the response.""" 191 | response = self.client.post( 192 | url, data=payload, format="json", **self.authorization_header 193 | ) 194 | self.assertEqual(response.status_code, status_code) 195 | return response 196 | -------------------------------------------------------------------------------- /netbox_diode_plugin/tests/test_version.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2025 NetBox Labs, Inc. 3 | """Diode NetBox Plugin - Tests.""" 4 | 5 | from django.test import TestCase 6 | 7 | from netbox_diode_plugin.version import version_display, version_semver 8 | 9 | 10 | class VersionTestCase(TestCase): 11 | """Test case for the version module.""" 12 | 13 | def test_version(self): 14 | """Check the injected semver.""" 15 | assert version_semver() == "0.0.0" 16 | 17 | def test_version_display(self): 18 | """Check the injected display.""" 19 | assert version_display() == "v0.0.0-dev-unknown" 20 | -------------------------------------------------------------------------------- /netbox_diode_plugin/tests/test_views.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2025 NetBox Labs, Inc. 3 | """Diode NetBox Plugin - Tests.""" 4 | from unittest import mock 5 | 6 | from django.contrib.auth import get_user_model 7 | from django.contrib.auth.models import AnonymousUser 8 | from django.contrib.messages.middleware import MessageMiddleware 9 | from django.contrib.messages.storage.fallback import FallbackStorage 10 | from django.contrib.sessions.middleware import SessionMiddleware 11 | from django.test import RequestFactory, TestCase 12 | from django.urls import reverse 13 | from rest_framework import status 14 | 15 | from netbox_diode_plugin.models import Setting 16 | from netbox_diode_plugin.views import SettingsEditView, SettingsView 17 | 18 | User = get_user_model() 19 | 20 | 21 | class SettingsViewTestCase(TestCase): 22 | """Test case for the SettingsView.""" 23 | 24 | def setUp(self): 25 | """Setup the test case.""" 26 | self.path = reverse("plugins:netbox_diode_plugin:settings") 27 | self.request = RequestFactory().get(self.path) 28 | self.view = SettingsView() 29 | self.view.setup(self.request) 30 | 31 | def test_returns_200_for_authenticated(self): 32 | """Test that the view returns 200 for an authenticated user.""" 33 | self.request.user = User.objects.create_user("foo", password="pass") 34 | self.request.user.is_staff = True 35 | 36 | response = self.view.get(self.request) 37 | self.assertEqual(response.status_code, status.HTTP_200_OK) 38 | 39 | def test_redirects_to_login_page_for_unauthenticated_user(self): 40 | """Test that the view returns 200 for an authenticated user.""" 41 | self.request.user = AnonymousUser() 42 | self.view.setup(self.request) 43 | 44 | response = SettingsView.as_view()(self.request) 45 | 46 | self.assertEqual(response.status_code, status.HTTP_302_FOUND) 47 | self.assertEqual(response.url, f"/netbox/login/?next={self.path}") 48 | 49 | def test_settings_created_if_not_found(self): 50 | """Test that the settings are created with placeholder data if not found.""" 51 | self.request.user = User.objects.create_user("foo", password="pass") 52 | self.request.user.is_staff = True 53 | 54 | with mock.patch("netbox_diode_plugin.models.Setting.objects.get") as mock_get: 55 | mock_get.side_effect = Setting.DoesNotExist 56 | 57 | response = self.view.get(self.request) 58 | self.assertEqual(response.status_code, status.HTTP_200_OK) 59 | self.assertIn("grpc://localhost:8080/diode", str(response.content)) 60 | 61 | 62 | class SettingsEditViewTestCase(TestCase): 63 | """Test case for the SettingsEditView.""" 64 | 65 | def setUp(self): 66 | """Setup the test case.""" 67 | self.path = reverse("plugins:netbox_diode_plugin:settings_edit") 68 | self.request_factory = RequestFactory() 69 | self.view = SettingsEditView() 70 | 71 | def test_returns_200_for_authenticated(self): 72 | """Test that the view returns 200 for an authenticated user.""" 73 | request = self.request_factory.get(self.path) 74 | request.user = User.objects.create_user("foo", password="pass") 75 | request.user.is_staff = True 76 | request.htmx = None 77 | self.view.setup(request) 78 | 79 | response = self.view.get(request) 80 | self.assertEqual(response.status_code, status.HTTP_200_OK) 81 | 82 | def test_redirects_to_login_page_for_unauthenticated_user(self): 83 | """Test that the view redirects an authenticated user to login page.""" 84 | request = self.request_factory.get(self.path) 85 | request.user = AnonymousUser() 86 | self.view.setup(request) 87 | 88 | response = self.view.get(request) 89 | self.assertEqual(response.status_code, status.HTTP_302_FOUND) 90 | self.assertEqual(response.url, f"/netbox/login/?next={self.path}") 91 | 92 | def test_settings_updated(self): 93 | """Test that the settings are updated.""" 94 | user = User.objects.create_user("foo", password="pass") 95 | user.is_staff = True 96 | 97 | request = self.request_factory.get(self.path) 98 | request.user = user 99 | request.htmx = None 100 | self.view.setup(request) 101 | 102 | response = self.view.get(request) 103 | self.assertEqual(response.status_code, status.HTTP_200_OK) 104 | self.assertIn("grpc://localhost:8080/diode", str(response.content)) 105 | 106 | request = self.request_factory.post(self.path) 107 | request.user = user 108 | request.htmx = None 109 | request.POST = {"diode_target": "grpc://localhost:8090/diode"} 110 | 111 | middleware = SessionMiddleware(get_response=lambda request: None) 112 | middleware.process_request(request) 113 | request.session.save() 114 | 115 | middleware = MessageMiddleware(get_response=lambda request: None) 116 | middleware.process_request(request) 117 | request.session.save() 118 | 119 | response = self.view.post(request) 120 | self.assertEqual(response.status_code, status.HTTP_302_FOUND) 121 | self.assertEqual(response.url, reverse("plugins:netbox_diode_plugin:settings")) 122 | 123 | request = self.request_factory.get(self.path) 124 | request.user = user 125 | request.htmx = None 126 | self.view.setup(request) 127 | 128 | response = self.view.get(request) 129 | self.assertEqual(response.status_code, status.HTTP_200_OK) 130 | self.assertIn("grpc://localhost:8090/diode", str(response.content)) 131 | 132 | def test_settings_update_post_redirects_to_login_page_for_unauthenticated_user( 133 | self, 134 | ): 135 | """Test that the view redirects an authenticated user to login page.""" 136 | request = self.request_factory.post(self.path) 137 | request.user = AnonymousUser() 138 | request.htmx = None 139 | request.POST = {"diode_target": "grpc://localhost:8090/diode"} 140 | 141 | response = self.view.post(request) 142 | self.assertEqual(response.status_code, status.HTTP_302_FOUND) 143 | self.assertEqual(response.url, f"/netbox/login/?next={self.path}") 144 | 145 | def test_settings_update_disallowed_on_get_method(self): 146 | """Test that the accessing settings edit is not allowed with diode target override.""" 147 | with mock.patch( 148 | "netbox_diode_plugin.views.get_plugin_config" 149 | ) as mock_get_plugin_config: 150 | mock_get_plugin_config.return_value = "grpc://localhost:8080/diode" 151 | 152 | user = User.objects.create_user("foo", password="pass") 153 | user.is_staff = True 154 | 155 | request = self.request_factory.post(self.path) 156 | request.user = user 157 | request.htmx = None 158 | 159 | middleware = SessionMiddleware(get_response=lambda request: None) 160 | middleware.process_request(request) 161 | request.session.save() 162 | 163 | middleware = MessageMiddleware(get_response=lambda request: None) 164 | middleware.process_request(request) 165 | request.session.save() 166 | 167 | setattr(request, "session", "session") 168 | messages = FallbackStorage(request) 169 | request._messages = messages 170 | 171 | self.view.setup(request) 172 | response = self.view.get(request) 173 | 174 | self.assertEqual(response.status_code, status.HTTP_302_FOUND) 175 | self.assertEqual( 176 | response.url, reverse("plugins:netbox_diode_plugin:settings") 177 | ) 178 | self.assertEqual(len(request._messages._queued_messages), 1) 179 | self.assertEqual( 180 | str(request._messages._queued_messages[0]), 181 | "The Diode target is not allowed to be modified.", 182 | ) 183 | 184 | def test_settings_update_disallowed_on_post_method(self): 185 | """Test that the updating settings is not allowed with diode target override.""" 186 | with mock.patch( 187 | "netbox_diode_plugin.views.get_plugin_config" 188 | ) as mock_get_plugin_config: 189 | mock_get_plugin_config.return_value = "grpc://localhost:8080/diode" 190 | 191 | user = User.objects.create_user("foo", password="pass") 192 | user.is_staff = True 193 | 194 | request = self.request_factory.post(self.path) 195 | request.user = user 196 | request.htmx = None 197 | request.POST = {"diode_target": "grpc://localhost:8090/diode"} 198 | 199 | middleware = SessionMiddleware(get_response=lambda request: None) 200 | middleware.process_request(request) 201 | request.session.save() 202 | 203 | middleware = MessageMiddleware(get_response=lambda request: None) 204 | middleware.process_request(request) 205 | request.session.save() 206 | 207 | setattr(request, "session", "session") 208 | messages = FallbackStorage(request) 209 | request._messages = messages 210 | 211 | self.view.setup(request) 212 | response = self.view.post(request) 213 | 214 | self.assertEqual(response.status_code, status.HTTP_302_FOUND) 215 | self.assertEqual( 216 | response.url, reverse("plugins:netbox_diode_plugin:settings") 217 | ) 218 | self.assertEqual(len(request._messages._queued_messages), 1) 219 | self.assertEqual( 220 | str(request._messages._queued_messages[0]), 221 | "The Diode target is not allowed to be modified.", 222 | ) 223 | -------------------------------------------------------------------------------- /netbox_diode_plugin/urls.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2025 NetBox Labs, Inc. 3 | """Diode Netbox Plugin - URLs.""" 4 | 5 | from django.urls import path 6 | 7 | from . import views 8 | 9 | urlpatterns = ( 10 | path("settings/", views.SettingsView.as_view(), name="settings"), 11 | path("settings/edit/", views.SettingsEditView.as_view(), name="settings_edit"), 12 | path("credentials/", views.ClientCredentialListView.as_view(), name="client_credential_list"), 13 | path("credentials/add/", views.ClientCredentialAddView.as_view(), name="client_credential_add"), 14 | path("credentials/secret/", views.ClientCredentialSecretView.as_view(), name="client_credential_secret"), 15 | path("credentials/delete//", views.ClientCredentialDeleteView.as_view(), name="client_credential_delete"), 16 | ) 17 | -------------------------------------------------------------------------------- /netbox_diode_plugin/version.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2025 NetBox Labs, Inc. 3 | """Version stamp.""" 4 | 5 | # These properties are injected at build time by the build process. 6 | 7 | __commit_hash__ = "unknown" 8 | __track__ = "dev" 9 | __version__ = "0.0.0" 10 | 11 | 12 | def version_display(): 13 | """Display the version, track and hash together.""" 14 | return f"v{__version__}-{__track__}-{__commit_hash__}" 15 | 16 | 17 | def version_semver(): 18 | """Semantic version.""" 19 | return __version__ 20 | -------------------------------------------------------------------------------- /netbox_diode_plugin/views.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright 2025 NetBox Labs, Inc. 3 | """Diode NetBox Plugin - Views.""" 4 | import logging 5 | 6 | from django.conf import settings as netbox_settings 7 | from django.contrib import messages 8 | from django.contrib.auth import get_user_model 9 | from django.http import HttpResponseRedirect 10 | from django.shortcuts import redirect, render 11 | from django.urls import reverse 12 | from django.utils.http import url_has_allowed_host_and_scheme 13 | from django.utils.translation import gettext as _ 14 | from django.views.generic import View 15 | from netbox.plugins import get_plugin_config 16 | from netbox.views import generic 17 | from utilities.forms import ConfirmationForm 18 | from utilities.htmx import htmx_partial 19 | from utilities.permissions import get_permission_for_model 20 | from utilities.views import register_model_view 21 | 22 | from netbox_diode_plugin.client import create_client, delete_client, get_client, list_clients 23 | from netbox_diode_plugin.forms import ClientCredentialForm, SettingsForm 24 | from netbox_diode_plugin.models import ClientCredentials, Setting 25 | from netbox_diode_plugin.tables import ClientCredentialsTable 26 | 27 | User = get_user_model() 28 | 29 | 30 | logger = logging.getLogger(__name__) 31 | 32 | def redirect_to_login(request): 33 | """Redirect to login view.""" 34 | redirect_url = netbox_settings.LOGIN_URL 35 | target = request.path 36 | 37 | if target and url_has_allowed_host_and_scheme(target, allowed_hosts=None): 38 | redirect_url = f"{netbox_settings.LOGIN_URL}?next={target}" 39 | 40 | return HttpResponseRedirect(redirect_url) 41 | 42 | 43 | class SettingsView(View): 44 | """Settings view.""" 45 | 46 | def get(self, request): 47 | """Render settings template.""" 48 | if not request.user.is_authenticated or not request.user.is_staff: 49 | return redirect_to_login(request) 50 | 51 | diode_target_override = get_plugin_config( 52 | "netbox_diode_plugin", "diode_target_override" 53 | ) 54 | 55 | try: 56 | settings = Setting.objects.get() 57 | except Setting.DoesNotExist: 58 | default_diode_target = get_plugin_config( 59 | "netbox_diode_plugin", "diode_target" 60 | ) 61 | settings = Setting.objects.create( 62 | diode_target=diode_target_override or default_diode_target 63 | ) 64 | 65 | diode_target = diode_target_override or settings.diode_target 66 | 67 | context = { 68 | "diode_target": diode_target, 69 | "is_diode_target_overridden": diode_target_override is not None, 70 | } 71 | 72 | return render(request, "diode/settings.html", context) 73 | 74 | 75 | @register_model_view(Setting, "edit") 76 | class SettingsEditView(generic.ObjectEditView): 77 | """Settings edit view.""" 78 | 79 | queryset = Setting.objects 80 | form = SettingsForm 81 | template_name = "diode/settings_edit.html" 82 | default_return_url = "plugins:netbox_diode_plugin:settings" 83 | 84 | def get(self, request, *args, **kwargs): 85 | """GET request handler.""" 86 | if not request.user.is_authenticated or not request.user.is_staff: 87 | return redirect_to_login(request) 88 | 89 | diode_target_override = get_plugin_config( 90 | "netbox_diode_plugin", "diode_target_override" 91 | ) 92 | if diode_target_override: 93 | messages.error( 94 | request, 95 | "The Diode target is not allowed to be modified.", 96 | ) 97 | return redirect("plugins:netbox_diode_plugin:settings") 98 | 99 | settings = Setting.objects.get() 100 | kwargs["pk"] = settings.pk 101 | 102 | return super().get(request, *args, **kwargs) 103 | 104 | def post(self, request, *args, **kwargs): 105 | """POST request handler.""" 106 | if not request.user.is_authenticated or not request.user.is_staff: 107 | return redirect_to_login(request) 108 | 109 | diode_target_override = get_plugin_config( 110 | "netbox_diode_plugin", "diode_target_override" 111 | ) 112 | if diode_target_override: 113 | messages.error( 114 | request, 115 | "The Diode target is not allowed to be modified.", 116 | ) 117 | return redirect("plugins:netbox_diode_plugin:settings") 118 | 119 | settings = Setting.objects.get() 120 | kwargs["pk"] = settings.pk 121 | 122 | return super().post(request, *args, **kwargs) 123 | 124 | 125 | class GetReturnURLMixin: 126 | """Get return URL mixin.""" 127 | 128 | def get_return_url(self, request): 129 | """Get return URL.""" 130 | # First, see if `return_url` was specified as a query parameter or form data. Use this URL only if it's 131 | # considered safe. 132 | return_url = request.GET.get("return_url") or request.POST.get("return_url") 133 | if return_url and url_has_allowed_host_and_scheme( 134 | return_url, allowed_hosts=None 135 | ): 136 | return return_url 137 | 138 | return None 139 | 140 | 141 | class BaseDiodeView(View): 142 | """Base diode view.""" 143 | 144 | def check_authentication(self, request): 145 | """Check authentication.""" 146 | if not request.user.is_authenticated or not request.user.is_staff: 147 | return redirect_to_login(request) 148 | return None 149 | 150 | def get_required_permission(self): 151 | """Get required permission.""" 152 | return get_permission_for_model(self.model, "view") 153 | 154 | class ClientCredentialListView(BaseDiodeView): 155 | """Client credential list view.""" 156 | 157 | table = ClientCredentialsTable 158 | template_name = "diode/client_credential_list.html" 159 | model = ClientCredentials 160 | 161 | def get_table_data(self, request): 162 | """Get table data.""" 163 | try: 164 | data = list_clients(request) 165 | total = len(data) 166 | except Exception as e: 167 | logger.debug(f"Error loading client credentials error: {str(e)}") 168 | messages.error(self.request, str(e)) 169 | data = [] 170 | total = 0 171 | 172 | return total, data 173 | 174 | def get(self, request): 175 | """GET request handler.""" 176 | if ret := self.check_authentication(request): 177 | return ret 178 | 179 | total, data = self.get_table_data(request) 180 | table = self.table(data=data) # Pass the data to the table 181 | 182 | # If this is an HTMX request, return only the rendered table HTML 183 | if htmx_partial(request): 184 | if request.GET.get("embedded", False): 185 | table.embedded = True 186 | # Hide selection checkboxes 187 | if "pk" in table.base_columns: 188 | table.columns.hide("pk") 189 | return render( 190 | request, 191 | "htmx/table.html", 192 | { 193 | "model": ClientCredentials, 194 | "table": table, 195 | "total_count": len(data), 196 | }, 197 | ) 198 | 199 | context = { 200 | "model": ClientCredentials, 201 | "table": table, 202 | "total_count": len(data), 203 | } 204 | 205 | return render(request, self.template_name, context) 206 | 207 | 208 | class ClientCredentialDeleteView(GetReturnURLMixin, BaseDiodeView): 209 | """Client credential delete view.""" 210 | 211 | template_name = "diode/client_credential_delete.html" 212 | default_return_url = "plugins:netbox_diode_plugin:client_credential_list" 213 | 214 | def get(self, request, client_credential_id): 215 | """GET request handler.""" 216 | if ret := self.check_authentication(request): 217 | return ret 218 | 219 | data = get_client(request, client_credential_id) 220 | 221 | return render( 222 | request, 223 | self.template_name, 224 | { 225 | "object": data, 226 | "object_type": "Client Credential", 227 | "return_url": self.get_return_url(request) or reverse(self.default_return_url), 228 | }, 229 | ) 230 | 231 | def post(self, request, client_credential_id): 232 | """POST request handler.""" 233 | sanitized_client_credential_id = client_credential_id.replace('\n', '').replace('\r', '') 234 | logger.info(f"Deleting client {sanitized_client_credential_id}") 235 | if ret := self.check_authentication(request): 236 | return ret 237 | 238 | form = ConfirmationForm(request.POST) 239 | if form.is_valid(): 240 | try: 241 | delete_client(request, client_credential_id) 242 | messages.success(request, _("Client deleted successfully")) 243 | except Exception as e: 244 | logger.error( 245 | f"Error deleting client: {sanitized_client_credential_id} error: {str(e)}" 246 | ) 247 | messages.error(request, str(e)) 248 | 249 | return redirect( 250 | reverse( 251 | "plugins:netbox_diode_plugin:client_credential_list", 252 | ) 253 | ) 254 | 255 | 256 | class ClientCredentialAddView(GetReturnURLMixin, BaseDiodeView): 257 | """View for adding client credentials.""" 258 | 259 | template_name = "diode/client_credential_add.html" 260 | form_class = ClientCredentialForm 261 | default_return_url = "plugins:netbox_diode_plugin:client_credential_list" 262 | 263 | def get(self, request): 264 | """GET request handler.""" 265 | if ret := self.check_authentication(request): 266 | return ret 267 | 268 | form = self.form_class() 269 | return render( 270 | request, 271 | self.template_name, 272 | { 273 | "form": form, 274 | "return_url": self.get_return_url(request) or reverse(self.default_return_url), 275 | }, 276 | ) 277 | 278 | def post(self, request): 279 | """POST request handler.""" 280 | if ret := self.check_authentication(request): 281 | return ret 282 | 283 | form = self.form_class(request.POST) 284 | if form.is_valid(): 285 | try: 286 | response = create_client(request, form.cleaned_data["client_name"], "diode:ingest") 287 | # Store the client credentials in session 288 | request.session['client_secret'] = response.get('client_secret') 289 | request.session['client_name'] = form.cleaned_data["client_name"] 290 | request.session['client_id'] = response.get('client_id') 291 | return redirect( 292 | reverse( 293 | "plugins:netbox_diode_plugin:client_credential_secret", 294 | ) 295 | ) 296 | except Exception as e: 297 | logger.error(f"Error creating client: {str(e)}") 298 | messages.error(request, str(e)) 299 | 300 | return render( 301 | request, 302 | self.template_name, 303 | { 304 | "form": form, 305 | "return_url": self.get_return_url(request) or reverse(self.default_return_url), 306 | }, 307 | ) 308 | 309 | 310 | class ClientCredentialSecretView(BaseDiodeView): 311 | """View for displaying client secret.""" 312 | 313 | template_name = "diode/client_credential_secret.html" 314 | 315 | def get(self, request): 316 | """Get request handler.""" 317 | if ret := self.check_authentication(request): 318 | return ret 319 | 320 | # Get the client secret from session 321 | client_secret = request.session.get('client_secret') 322 | client_name = request.session.get('client_name') 323 | client_id = request.session.get('client_id') 324 | 325 | if not client_secret: 326 | messages.error(request, _("No client secret found. Please create a new client.")) 327 | return redirect( 328 | reverse( 329 | "plugins:netbox_diode_plugin:client_credential_list", 330 | ) 331 | ) 332 | 333 | # Clear the session data after retrieving it 334 | request.session.pop('client_secret', None) 335 | request.session.pop('client_name', None) 336 | request.session.pop('client_id', None) 337 | 338 | return render( 339 | request, 340 | self.template_name, 341 | { 342 | "object": { 343 | "client_name": client_name, 344 | "client_id": client_id, 345 | "client_secret": client_secret, 346 | } 347 | }, 348 | ) 349 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "netboxlabs-diode-netbox-plugin" 3 | version = "0.0.1" # Overwritten during the build process 4 | description = "NetBox Labs, Diode NetBox plugin" 5 | readme = "README.md" 6 | requires-python = ">=3.8" 7 | license = { text = "NetBox Limited Use License 1.0" } 8 | authors = [ 9 | {name = "NetBox Labs", email = "support@netboxlabs.com" } 10 | ] 11 | maintainers = [ 12 | {name = "NetBox Labs", email = "support@netboxlabs.com" } 13 | ] 14 | 15 | classifiers = [ 16 | "Development Status :: 4 - Beta", 17 | "Intended Audience :: Developers", 18 | "Topic :: Software Development :: Build Tools", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3 :: Only", 21 | 'Programming Language :: Python :: 3.8', 22 | 'Programming Language :: Python :: 3.9', 23 | 'Programming Language :: Python :: 3.10', 24 | 'Programming Language :: Python :: 3.11', 25 | ] 26 | 27 | dependencies = [ 28 | "Brotli>=1.1.0", 29 | "certifi>=2024.7.4", 30 | "grpcio>=1.68.1", 31 | "protobuf>=5.28.1", 32 | ] 33 | 34 | [project.optional-dependencies] 35 | dev = ["black", "check-manifest", "ruff"] 36 | test = ["coverage", "pytest", "pytest-cov"] 37 | 38 | [project.urls] 39 | "Homepage" = "https://netboxlabs.com/" 40 | 41 | [project.scripts] 42 | 43 | [tool.setuptools] 44 | packages = [ 45 | "netbox_diode_plugin", 46 | ] 47 | package-data = {"netbox_diode_plugin" = ["**/*", "templates/**"]} 48 | exclude-package-data = {netbox_diode_plugin = ["tests/*"]} 49 | license-files = ["LICENSE.md"] 50 | 51 | [build-system] 52 | requires = ["setuptools>=43.0.0", "wheel"] 53 | build-backend = "setuptools.build_meta" 54 | 55 | [tool.ruff] 56 | line-length = 140 57 | exclude = [ 58 | "*_pb2*", 59 | "netbox_diode_plugin/api/plugin_utils.py", 60 | "docker/*", 61 | ] 62 | 63 | [tool.ruff.format] 64 | quote-style = "double" 65 | indent-style = "space" 66 | 67 | [tool.ruff.lint] 68 | select = ["C", "D", "E", "F", "I", "R", "UP", "W"] 69 | ignore = ["F401", "D203", "D212", "D400", "D401", "D404", "RET504"] 70 | --------------------------------------------------------------------------------