├── .codeclimate.yml ├── .coveragerc ├── .editorconfig ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── octodns_netbox ├── __init__.py └── reversename.py ├── pyproject.toml ├── renovate.json ├── tests ├── __init__.py ├── fixtures │ ├── ip_addresses_example_com.json │ ├── ip_addresses_subdomain1_example_com.json │ ├── ip_addresses_v4_non_octet_boundary.json │ ├── ip_addresses_v4_non_octet_boundary_vrf_mgmt.json │ ├── ip_addresses_v4_octet_boundary.json │ ├── ip_addresses_v4_octet_boundary_vrf_global.json │ ├── ip_addresses_v6_nibble_boundary.json │ ├── ip_addresses_v6_non_nibble_boundary.json │ ├── vrf.json │ └── vrfs.json ├── test_octodns_netbox.py ├── test_reversename.py └── util.py └── tox.ini /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | checks: 3 | argument-count: 4 | config: 5 | threshold: 5 6 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | # uncomment the following to omit files during running 3 | #omit = 4 | [report] 5 | exclude_lines = 6 | pragma: no cover 7 | def __repr__ 8 | if self.debug: 9 | if settings.DEBUG 10 | raise AssertionError 11 | raise NotImplementedError 12 | if 0: 13 | if __name__ == .__main__.: 14 | def main 15 | if TYPE_CHECKING: 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: dev workflow 4 | 5 | # Controls when the action will run. 6 | on: 7 | # Triggers the workflow on every pull request events 8 | pull_request: 9 | types: 10 | - opened 11 | - synchronize 12 | - reopened 13 | 14 | push: 15 | branches: 16 | - main 17 | 18 | # Allows you to run this workflow manually from the Actions tab 19 | workflow_dispatch: 20 | 21 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 22 | jobs: 23 | # This workflow contains a single job called "build" 24 | test: 25 | # The type of runner that the job will run on 26 | name: Build and Test 27 | strategy: 28 | matrix: 29 | python-versions: ['3.9', '3.10', '3.11', '3.12', '3.13'] 30 | os: [ubuntu-latest] 31 | runs-on: ${{ matrix.os }} 32 | 33 | # Steps represent a sequence of tasks that will be executed as part of the job 34 | steps: 35 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 36 | - uses: actions/checkout@v4 37 | with: 38 | fetch-depth: 0 39 | 40 | - uses: actions/setup-python@v5 41 | with: 42 | python-version: ${{ matrix.python-versions }} 43 | 44 | - name: Install dependencies 45 | run: | 46 | python -m pip install --upgrade pip 47 | pip install tox tox-gh-actions poetry 48 | 49 | - name: Test with tox 50 | run: tox 51 | 52 | - name: 📥 Upload coverage.xml to Artifact 53 | uses: actions/upload-artifact@v4 54 | with: 55 | path: ${{ github.workspace }}/coverage.xml 56 | name: ${{ github.run_number }}-coverage-${{ matrix.os }}-${{ matrix.python-versions }} 57 | 58 | publish_coverage_codeclimate: 59 | name: 🚀 Publish code coverage to Code Climate 60 | needs: test 61 | runs-on: ubuntu-latest 62 | steps: 63 | - uses: actions/checkout@v4 64 | with: 65 | fetch-depth: 0 66 | 67 | - name: 👀 Download all artifacts within the workflow run 68 | id: download 69 | uses: actions/download-artifact@v4 70 | 71 | - name: 🔍 Display structure of downloaded files 72 | run: ls -Rla ${{steps.download.outputs.download-path}} 73 | 74 | - name: 🚀 Publish code coverage to Code Climate 75 | uses: paambaati/codeclimate-action@v9.0.0 76 | env: 77 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 78 | with: 79 | debug: true 80 | coverageLocations: | 81 | ${{steps.download.outputs.download-path}}/${{ github.run_number }}-coverage-*/coverage.xml:coverage.py 82 | 83 | - name: 🗑 Delete artifacts within the workflow run 84 | uses: geekyeggo/delete-artifact@v5 85 | if: always() 86 | with: 87 | name: ${{ github.run_number }}-coverage-* 88 | 89 | publish_dev_build: 90 | # if test failed, we should not publish 91 | name: Publish (Test PyPI) 92 | needs: test 93 | if: github.ref == 'refs/heads/main' && github.event_name == 'push' 94 | runs-on: ubuntu-latest 95 | steps: 96 | - uses: actions/checkout@v4 97 | with: 98 | fetch-depth: 0 99 | 100 | - uses: actions/setup-python@v5 101 | with: 102 | python-version: '3.13' 103 | 104 | - name: Install dependencies 105 | run: | 106 | python -m pip install --upgrade pip 107 | pip install poetry 108 | poetry install 109 | 110 | - name: Build wheels and source tarball 111 | run: | 112 | poetry build 113 | 114 | - name: Publish distribution 📦 to Test PyPI 115 | uses: pypa/gh-action-pypi-publish@release/v1 116 | with: 117 | user: __token__ 118 | password: ${{ secrets.TEST_PYPI_API_TOKEN}} 119 | repository_url: https://test.pypi.org/legacy/ 120 | skip_existing: true 121 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # Publish package on release branch if it's tagged with 'v*' 2 | 3 | name: release & publish workflow 4 | 5 | # Controls when the action will run. 6 | on: 7 | workflow_dispatch: 8 | inputs: 9 | version: 10 | description: Release Version 11 | required: true 12 | default: N.N.N.devN 13 | 14 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 15 | jobs: 16 | # This workflow contains a single job called "build" 17 | release: 18 | name: Create Release 19 | runs-on: ubuntu-latest 20 | 21 | strategy: 22 | matrix: 23 | python-versions: ['3.13'] 24 | 25 | # Steps represent a sequence of tasks that will be executed as part of the job 26 | steps: 27 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 28 | - uses: actions/checkout@v4 29 | with: 30 | fetch-depth: 0 31 | 32 | - uses: actions/setup-python@v5 33 | with: 34 | python-version: ${{ matrix.python-versions }} 35 | 36 | - name: Install dependencies 37 | run: | 38 | python -m pip install --upgrade pip 39 | pip install poetry 40 | poetry install 41 | 42 | - name: Push tag 43 | run: | 44 | git config user.name "${GITHUB_ACTOR}" 45 | git config user.email "${GITHUB_ACTOR}@users.noreply.github.com" 46 | git tag "v${{ github.event.inputs.version }}" --force 47 | git push origin "v${{ github.event.inputs.version }}" --force 48 | 49 | - name: Build wheels and source tarball 50 | run: >- 51 | poetry build 52 | 53 | - name: show temporary files 54 | run: >- 55 | ls -l 56 | 57 | - name: Create github release 58 | id: create_release 59 | uses: softprops/action-gh-release@v2 60 | env: 61 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 62 | with: 63 | tag_name: v${{ github.event.inputs.version }} 64 | files: dist/*.whl 65 | draft: false 66 | prerelease: contains(github.event.inputs.version, 'dev') 67 | generate_release_notes: true 68 | 69 | - name: Publish distribution 📦 to PyPI 70 | uses: pypa/gh-action-pypi-publish@release/v1 71 | with: 72 | user: __token__ 73 | password: ${{ secrets.PYPI_API_TOKEN}} 74 | skip_existing: true 75 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | # poetry lock file 104 | poetry.lock 105 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/Lucas-C/pre-commit-hooks 3 | rev: v1.5.5 4 | hooks: 5 | - id: forbid-tabs 6 | - id: remove-tabs 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: v5.0.0 9 | hooks: 10 | - id: check-added-large-files 11 | - id: check-builtin-literals 12 | - id: check-case-conflict 13 | - id: check-yaml 14 | args: [--unsafe] 15 | - id: check-toml 16 | - id: debug-statements 17 | - id: end-of-file-fixer 18 | - id: forbid-new-submodules 19 | - id: trailing-whitespace 20 | - id: mixed-line-ending 21 | - repo: https://github.com/astral-sh/ruff-pre-commit 22 | # Ruff version. 23 | rev: v0.9.10 24 | hooks: 25 | # Run the linter. 26 | - id: ruff 27 | args: [--fix] 28 | # Run the formatter. 29 | - id: ruff-format 30 | - repo: https://github.com/pre-commit/mirrors-mypy 31 | rev: v1.15.0 32 | hooks: 33 | - id: mypy 34 | files: octodns_netbox 35 | args: [--ignore-missing-imports, --pretty] 36 | additional_dependencies: [types-requests] 37 | - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks 38 | rev: v2.14.0 39 | hooks: 40 | - id: pretty-format-yaml 41 | args: [--autofix] 42 | - id: pretty-format-toml 43 | args: [--autofix] 44 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome, and they are greatly appreciated! Every little bit 4 | helps, and credit will always be given. 5 | 6 | You can contribute in many ways: 7 | 8 | ## Types of Contributions 9 | 10 | ### Report Bugs 11 | 12 | Report bugs at https://github.com/sukiyaki/octodns-netbox/issues. 13 | 14 | If you are reporting a bug, please include: 15 | 16 | * Your operating system name and version. 17 | * Any details about your local setup that might be helpful in troubleshooting. 18 | * Detailed steps to reproduce the bug. 19 | 20 | ### Fix Bugs 21 | 22 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help 23 | wanted" is open to whoever wants to implement it. 24 | 25 | ### Implement Features 26 | 27 | Look through the GitHub issues for features. Anything tagged with "enhancement" 28 | and "help wanted" is open to whoever wants to implement it. 29 | 30 | ### Write Documentation 31 | 32 | pysesame3 could always use more documentation, whether as part of the 33 | official pysesame3 docs, in docstrings, or even on the web in blog posts, 34 | articles, and such. 35 | 36 | ### Submit Feedback 37 | 38 | The best way to send feedback is to file an issue at https://github.com/sukiyaki/octodns-netbox/issues. 39 | 40 | If you are proposing a feature: 41 | 42 | * Explain in detail how it would work. 43 | * Keep the scope as narrow as possible, to make it easier to implement. 44 | * Remember that this is a volunteer-driven project, and that contributions 45 | are welcome :) 46 | 47 | ## Get Started! 48 | 49 | Ready to contribute? Here's how to set up `octodns-netbox` for local development. 50 | 51 | 1. Fork the `octodns-netbox` repo on GitHub. 52 | 2. Clone your fork locally 53 | 54 | ``` 55 | $ git clone git@github.com:your_name_here/octodns-netbox.git 56 | ``` 57 | 58 | 3. Ensure [poetry](https://python-poetry.org/docs/) is installed. 59 | 4. Install dependencies and start your virtualenv: 60 | 61 | ``` 62 | $ poetry install --with dev 63 | ``` 64 | 65 | 5. Create a branch for local development: 66 | 67 | ``` 68 | $ git checkout -b name-of-your-bugfix-or-feature 69 | ``` 70 | 71 | Now you can make your changes locally. 72 | 73 | 6. When you're done making changes, check that your changes pass the 74 | tests, including testing other Python versions, with tox: 75 | 76 | ``` 77 | $ tox 78 | ``` 79 | 80 | 7. Set up pre-commit hooks for automatic code formatting and linting: 81 | 82 | ``` 83 | $ pre-commit install 84 | ``` 85 | 86 | 8. Commit your changes and push your branch to GitHub: 87 | 88 | ``` 89 | $ git add . 90 | $ git commit -m "Your detailed description of your changes." 91 | $ git push origin name-of-your-bugfix-or-feature 92 | ``` 93 | 94 | 9. Submit a pull request through the GitHub website. 95 | 96 | ## Pull Request Guidelines 97 | 98 | Before you submit a pull request, check that it meets these guidelines: 99 | 100 | 1. The pull request should include tests. 101 | 2. If the pull request adds functionality, the docs should be updated. Put 102 | your new functionality into a function with a docstring, and add the 103 | feature to the list in README.md. 104 | 3. The pull request should work for Python 3.7, 3.8, 3.9 and 3.10. Check 105 | https://github.com/mochipon/octodns-netbox/actions 106 | and make sure that the tests pass for all supported Python versions. 107 | 108 | ## Tips 109 | 110 | To run a subset of tests: 111 | 112 | ``` 113 | $ pytest tests 114 | ``` 115 | 116 | To invoke the pre-commit hook manually: 117 | 118 | ``` 119 | $ pre-commit run 120 | ``` 121 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 sukiyaki project 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A [NetBox](https://github.com/digitalocean/netbox) source for [octoDNS](https://github.com/github/octodns/) 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/octodns-netbox)](https://pypi.python.org/pypi/octodns-netbox) 4 | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/octodns-netbox)](https://pypi.python.org/pypi/octodns-netbox) 5 | [![PyPI - License](https://img.shields.io/pypi/l/octodns-netbox)](LICENSE) 6 | [![Code Climate coverage](https://img.shields.io/codeclimate/coverage/sukiyaki/octodns-netbox)](https://codeclimate.com/github/sukiyaki/octodns-netbox) 7 | [![Code Climate maintainability](https://img.shields.io/codeclimate/maintainability/sukiyaki/octodns-netbox)](https://codeclimate.com/github/sukiyaki/octodns-netbox) 8 | 9 | This project provides a NetBox source for OctoDNS. It retrieves IP address information from NetBox so that OctoDNS creates corresponding A/AAAA and PTR records. 10 | 11 | **Note:** This is just a **source** for OctoDNS, not a **provider**. It only serve to populate records into a zone, cannot be synced to. 12 | 13 | ## Installation 14 | 15 | ``` 16 | pip install octodns-netbox 17 | ``` 18 | 19 | ## Getting started 20 | 21 | You must configure the `url` and `token` parameters in your YAML file to work with the NetBox API. You can also specify the TTL (Time to Live) for the generated records, but this parameter is optional, and the default value is 60. 22 | 23 | ```yaml 24 | providers: 25 | netbox: 26 | class: octodns_netbox.NetboxSource 27 | url: https://ipam.example.com 28 | token: env/NETBOX_TOKEN 29 | ttl: 60 30 | ``` 31 | 32 | ### A records / AAAA records 33 | 34 | To create A/AAAA records for octoDNS, you need to manage the mapping between IP addresses and fully qualified domain names (FQDNs) in NetBox. The `description` field is used for this purpose, and it should contain a comma-separated list of hostnames (FQDNs). 35 | 36 | Starting with [Netbox v2.6.0](https://github.com/netbox-community/netbox/issues/166), Netbox now has a `dns_name` field in IP address records. But we **do not** use this field by default because this `dns_name` field can only store **single** FQDN. To use a `dns_name` field, set `field_name: dns_name` in [the configuration](#examples). 37 | 38 | ### PTR records 39 | 40 | `octodns-netbox` also supports PTR records. By default, only the first FQDN in the field is used to generate the PTR record, but you can enable multiple PTR records for a single IP by setting the `multivalue_ptr` parameter to `true` in [the configuration](#examples). 41 | 42 | #### 🔍 Example (`multivalue_ptr: false` - default) 43 | - IP Address: `192.0.2.1/24` 44 | - Description: `en0.host1.example.com,host1.example.com` 45 | - DNS Zone: `2.0.192.in-addr.arpa.` 46 | - `1. PTR en0.host1.example.com.` 47 | 48 | #### 🔍 Example (`multivalue_ptr: true`) 49 | - IP Address: `192.0.2.1/24` 50 | - Description: `en0.host1.example.com,host1.example.com` 51 | - DNS Zone: `2.0.192.in-addr.arpa.` 52 | - `1. PTR en0.host1.example.com.` 53 | - `1. PTR host1.example.com.` 54 | 55 | #### Classless subnet delegation (IPv4 /31 to /25) 56 | 57 | If you are using classless subnets in Netbox, you can automatically expand records for the following format zones: 58 | 59 | - `-.2.0.192.in-addr.arpa` ([RFC 4183](https://www.rfc-editor.org/rfc/rfc4183.html) style) 60 | - `/.2.0.192.in-addr.arpa` ([RFC 2317](https://www.ietf.org/rfc/rfc2317.html) style) 61 | 62 | ## Examples 63 | 64 | Here is an example configuration for octodns-netbox: 65 | 66 | ```yaml 67 | providers: 68 | netbox: 69 | class: octodns_netbox.NetboxSource 70 | 71 | # Your Netbox URL 72 | url: https://ipam.example.com 73 | 74 | # Your Netbox Access Token (read-only) 75 | # This token should have read-only access to Netbox. 76 | token: env/NETBOX_TOKEN 77 | 78 | # The TTL of the generated records (Optional, default: 60) 79 | # Time to Live (TTL) specifies the time interval that a DNS record is stored in cache. 80 | # The default value of 60 is commonly used for dynamic DNS records. 81 | ttl: 60 82 | 83 | # Advanced Parameters: 84 | # The following parameters are optional and can be ignored for most use cases. 85 | 86 | # Generate records including subdomains (Optional, default: `true`) 87 | # If `false`, only records that belong directly to the zone (domain) will be generated. 88 | # This can be useful to reduce the number of DNS queries and avoid `SubzoneRecordException` errors. 89 | populate_subdomains: true 90 | 91 | # FQDN field name (Optional, default: `description`) 92 | # The `dns_name` field on Netbox is provided to hold only a single name, 93 | # but typically one IP address will correspond to multiple DNS records (FQDNs). 94 | # The `description` does not have any limitations so by default 95 | # we use the `description` field to store multiple FQDNs, separated by commas. 96 | # Other tested values are `dns_name`. 97 | field_name: description 98 | 99 | # Tag Name (Optional) 100 | # By default, all records are retrieved from Netbox, but it can be restricted 101 | # to only IP addresses assigned a specific tag. 102 | # Multiple values can be passed, resulting in a logical AND operation. 103 | populate_tags: 104 | - tag_name 105 | 106 | # VRF ID (Optional) 107 | # By default, all records are retrieved from Netbox, but it can be restricted 108 | # to only IP addresses assigned a specific VRF ID. 109 | # If `0`, it explicitly points to the global VRF. 110 | populate_vrf_id: 1 111 | 112 | # VRF Name (Optional) 113 | # VRF can also be specified by name. 114 | # If there are multiple VRFs with the same name, it would be better to use `populate_vrf_id`. 115 | # If `Global`, it explicitly points to the global VRF. 116 | populate_vrf_name: mgmt 117 | 118 | # Multi-value PTR records support (Optional, default: `false`) 119 | # If `true`, multiple-valued PTR records will be generated. 120 | # If `false`, the first FQDN value in the field will be used. 121 | multivalue_ptr: true 122 | 123 | route53: 124 | class: octodns_route53.Route53Provider 125 | access_key_id: env/AWS_ACCESS_KEY_ID 126 | secret_access_key: env/AWS_SECRET_ACCESS_KEY 127 | 128 | zones: 129 | example.com.: 130 | sources: 131 | - netbox # will add A/AAAA records 132 | targets: 133 | - route53 134 | 135 | 0/26.2.0.192.in-addr.arpa.: 136 | sources: 137 | - netbox # will add PTR records (corresponding to A records) 138 | targets: 139 | - route53 140 | 141 | 0.8.b.d.0.1.0.0.2.ip6.arpa: 142 | sources: 143 | - netbox # will add PTR records (corresponding to AAAA records) 144 | targets: 145 | - route53 146 | ``` 147 | 148 | ## Contributing 149 | See [the contributing guide](CONTRIBUTING.md) for detailed instructions on how to get started with our project. 150 | 151 | ## License 152 | [MIT](https://choosealicense.com/licenses/mit/) 153 | -------------------------------------------------------------------------------- /octodns_netbox/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | A NetBox source for octoDNS. 3 | 4 | Automatically creating A/AAAA records and their corresponding PTR records 5 | based on a NetBox API. 6 | """ 7 | 8 | import logging 9 | import re 10 | import typing 11 | from ipaddress import ip_interface 12 | 13 | from typing import Annotated, Literal 14 | 15 | import pynetbox 16 | import requests 17 | from octodns.record import Record, Rr 18 | from octodns.source.base import BaseSource 19 | from octodns.zone import SubzoneRecordException, Zone 20 | from pydantic import ( 21 | AnyHttpUrl, 22 | BaseModel, 23 | BeforeValidator, 24 | ConfigDict, 25 | Field, 26 | TypeAdapter, 27 | ValidationInfo, 28 | field_validator, 29 | ) 30 | 31 | import octodns_netbox.reversename 32 | 33 | # Type alias for URL validation 34 | Url = Annotated[ 35 | str, 36 | BeforeValidator(lambda value: str(TypeAdapter(AnyHttpUrl).validate_python(value))), 37 | ] 38 | 39 | 40 | class NetboxSourceConfig(BaseModel): 41 | """Configuration model for the NetboxSource.""" 42 | 43 | model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) 44 | 45 | multivalue_ptr: bool = False 46 | SUPPORTS_MULTIVALUE_PTR_: bool = Field( 47 | default_factory=lambda: False, 48 | alias="SUPPORTS_MULTIVALUE_PTR", 49 | description="Whether multiple PTR records are supported.", 50 | ) 51 | SUPPORTS_DYNAMIC_: bool = Field( 52 | False, 53 | alias="SUPPORTS_DYNAMIC", 54 | description="Whether dynamic records are supported.", 55 | ) 56 | SUPPORTS_GEO: bool = False 57 | SUPPORTS: typing.Set[str] = set(("A", "AAAA", "PTR")) 58 | 59 | id: str 60 | url: Url 61 | token: str 62 | field_name: str = "description" 63 | populate_tags: typing.List[str] = [] 64 | populate_vrf_id: Annotated[ 65 | typing.Union[int, Literal["null"], None], Field(validate_default=True) 66 | ] = None 67 | populate_vrf_name: Annotated[typing.Optional[str], Field(validate_default=True)] = ( 68 | None 69 | ) 70 | populate_subdomains: bool = True 71 | ttl: int = 60 72 | ssl_verify: bool = True 73 | log: logging.Logger 74 | 75 | @field_validator("url") 76 | def remove_trailing_api(cls, v: str) -> str: 77 | """ 78 | Removes any trailing '/api' or '/api/' from the URL. 79 | 80 | Args: 81 | v (str): The URL to be validated. 82 | 83 | Returns: 84 | str: A sanitized URL without trailing '/api'. 85 | """ 86 | if re.search(r"/api/?$", v): 87 | v = re.sub(r"/api/?$", "", v) 88 | return v 89 | 90 | @field_validator("populate_vrf_name") 91 | def validate_vrf_name( 92 | cls, v: typing.Optional[str], info: ValidationInfo 93 | ) -> typing.Optional[str]: 94 | """ 95 | Ensures that populate_vrf_name and populate_vrf_id 96 | are not set simultaneously. 97 | 98 | Args: 99 | v (Optional[str]): The VRF name to validate. 100 | info (ValidationInfo): Contains context about the overall model. 101 | 102 | Returns: 103 | Optional[str]: The validated VRF name. 104 | 105 | Raises: 106 | ValueError: If both populate_vrf_id and populate_vrf_name are set. 107 | """ 108 | data = info.data 109 | if v is not None and data.get("populate_vrf_id") is not None: 110 | raise ValueError("Do not set both populate_vrf_id and populate_vrf_name.") 111 | return v 112 | 113 | 114 | class NetboxSource(BaseSource, NetboxSourceConfig): 115 | """ 116 | NetboxSource class for octoDNS. 117 | 118 | This source fetches IP address data from NetBox based on certain filters 119 | (VRF, tags, etc.) and automatically creates A/AAAA and corresponding PTR 120 | records in octoDNS. 121 | """ 122 | 123 | def __init__(self, id: str, **kwargs: typing.Any): 124 | """ 125 | Initializes the NetboxSource by combining BaseSource and NetboxSourceConfig. 126 | 127 | Args: 128 | id (str): The unique identifier for this source. 129 | **kwargs (dict): Additional keyword arguments to configure the source. 130 | """ 131 | # Set the mandatory 'id' and a dedicated logger 132 | kwargs["id"] = id 133 | kwargs["log"] = logging.getLogger(f"{self.__class__.__name__}[{id}]") 134 | 135 | # Initialize the configuration model (NetboxSourceConfig) 136 | NetboxSourceConfig.__init__(self, **kwargs) 137 | 138 | # Initialize the octoDNS BaseSource 139 | BaseSource.__init__(self, id) 140 | 141 | self.log.debug( 142 | f"Initializing NetboxSource: id={id}, url={self.url}, " 143 | f"ttl={self.ttl}, ssl_verify={self.ssl_verify}" 144 | ) 145 | 146 | # Initialize NetBox client 147 | self._nb_client = pynetbox.api(url=self.url, token=self.token) 148 | session = requests.Session() 149 | session.verify = self.ssl_verify 150 | self._nb_client.http_session = session 151 | 152 | # Initialize VRF settings (if specified) 153 | self._init_vrf() 154 | 155 | def _init_vrf(self) -> None: 156 | """ 157 | Retrieves the VRF ID from NetBox if 'populate_vrf_name' is set, 158 | or handles the special "Global"/ID=0 case. 159 | """ 160 | if self._is_global_vrf(): 161 | self.populate_vrf_id = "null" 162 | return 163 | 164 | if self.populate_vrf_name is not None: 165 | self._set_vrf_id_from_name() 166 | 167 | def _is_global_vrf(self) -> bool: 168 | """ 169 | Determines if the user explicitly wants the 'Global' VRF or has ID=0. 170 | Interprets this as "null" in NetBox. 171 | 172 | Returns: 173 | bool: True if the VRF should be treated as global/null, False otherwise. 174 | """ 175 | return self.populate_vrf_name == "Global" or self.populate_vrf_id == 0 176 | 177 | def _set_vrf_id_from_name(self) -> None: 178 | """ 179 | Retrieves the VRF from NetBox by name and sets the 'populate_vrf_id'. 180 | Raises a ValueError if the VRF cannot be found. 181 | 182 | Raises: 183 | ValueError: If VRF name is invalid or not found in NetBox. 184 | """ 185 | try: 186 | vrf_obj = self._nb_client.ipam.vrfs.get(name=self.populate_vrf_name) 187 | if vrf_obj is None: 188 | raise ValueError( 189 | f"VRF '{self.populate_vrf_name}' not found. " 190 | "Use a valid name or VRF ID." 191 | ) 192 | self.populate_vrf_id = vrf_obj.id 193 | except (ValueError, AttributeError) as exc: 194 | raise ValueError( 195 | f"Failed to retrieve VRF information by name '{self.populate_vrf_name}'. " 196 | "Use a valid populate_vrf_id instead." 197 | ) from exc 198 | 199 | def populate(self, zone: Zone, target: bool = False, lenient: bool = False) -> None: 200 | """ 201 | Populates the given zone with records from NetBox. 202 | 203 | Args: 204 | zone (Zone): The octoDNS zone object to populate. 205 | target (bool, optional): Unused in this implementation. Defaults to False. 206 | lenient (bool, optional): If True, allows more permissive record handling. 207 | Defaults to False. 208 | """ 209 | self.log.debug( 210 | f"populate called for zone={zone.name}, target={target}, lenient={lenient}" 211 | ) 212 | before = len(zone.records) 213 | 214 | # Decide whether this is a reverse zone (PTR) or forward zone (A/AAAA) 215 | if zone.name.endswith(".in-addr.arpa."): 216 | # IPv4 reverse zone 217 | records = self._populate_ptr_records(zone, family=4) 218 | elif zone.name.endswith(".ip6.arpa."): 219 | # IPv6 reverse zone 220 | records = self._populate_ptr_records(zone, family=6) 221 | else: 222 | # Forward zone (A/AAAA) 223 | records = self._populate_forward_records(zone) 224 | 225 | self._add_records_to_zone(zone, records, lenient) 226 | self.log.info( 227 | "Populated %s new records in zone %s", len(zone.records) - before, zone.name 228 | ) 229 | 230 | def _populate_ptr_records( 231 | self, zone: Zone, family: Literal[4, 6] 232 | ) -> typing.List[Rr]: 233 | """ 234 | Populates PTR records for a reverse zone (in-addr.arpa or ip6.arpa). 235 | 236 | Args: 237 | zone (Zone): The reverse zone to populate. 238 | family (Literal[4, 6]): IP family (4 for IPv4, 6 for IPv6). 239 | 240 | Returns: 241 | List[Rr]: A list of Rr objects for PTR records. 242 | """ 243 | network = octodns_netbox.reversename.to_network(zone) 244 | filter_kwargs = self._build_ptr_filter_kwargs(network, family) 245 | 246 | ipam_records = self._nb_client.ipam.ip_addresses.filter(**filter_kwargs) 247 | return self._build_ptr_records(zone, ipam_records) 248 | 249 | def _build_ptr_filter_kwargs( 250 | self, network: typing.Any, family: Literal[4, 6] 251 | ) -> dict[str, typing.Any]: 252 | """ 253 | Builds the filter kwargs for NetBox queries when populating PTR records. 254 | 255 | Args: 256 | network (Any): The IP network derived from the reverse zone. 257 | family (Literal[4, 6]): The IP family (4 or 6). 258 | 259 | Returns: 260 | dict[str, Any]: A dictionary of filter criteria for NetBox. 261 | """ 262 | filter_kwargs = { 263 | f"{self.field_name}__empty": "false", 264 | "parent": network.compressed, 265 | "family": family, 266 | "vrf_id": self.populate_vrf_id, 267 | "tag": self.populate_tags, 268 | } 269 | 270 | # https://github.com/netbox-community/pynetbox/pull/545 271 | # From pynetbox v7.4.0, None will be mapped to null. 272 | # When vrf_id is null, it does not mean that it is not filtered by vrf_id, 273 | # but it would be an intention that VRF is not set. 274 | if filter_kwargs["vrf_id"] is None: 275 | del filter_kwargs["vrf_id"] 276 | 277 | return filter_kwargs 278 | 279 | def _build_ptr_records( 280 | self, 281 | zone: Zone, 282 | ipam_records: typing.Iterable[typing.Any], 283 | ) -> typing.List[Rr]: 284 | """ 285 | Builds PTR Rr objects from NetBox IPAM records. 286 | 287 | Args: 288 | zone (Zone): The reverse zone being populated. 289 | ipam_records (Iterable[Any]): IPAM records returned by NetBox filter query. 290 | 291 | Returns: 292 | List[Rr]: A list of PTR record objects to be added to the zone. 293 | """ 294 | records: typing.List[Rr] = [] 295 | for ipam_record in ipam_records: 296 | ip_address = ip_interface(ipam_record.address).ip 297 | ptr_name = zone.hostname_from_fqdn( 298 | octodns_netbox.reversename.from_address(zone, ip_address) 299 | ) 300 | # Potentially multiple FQDNs in the designated field 301 | fqdns = self._parse_fqdns_list( 302 | ipam_record[self.field_name], 303 | len_limit=None if self.multivalue_ptr else 1, 304 | ) 305 | for fqdn in fqdns: 306 | rr = Rr(ptr_name, "PTR", self.ttl, fqdn) 307 | self.log.debug(f"Adding PTR record {rr} to zone {zone.name}") 308 | records.append(rr) 309 | return records 310 | 311 | def _populate_forward_records(self, zone: Zone) -> typing.List[Rr]: 312 | """ 313 | Populates A/AAAA records for a forward zone. 314 | 315 | Args: 316 | zone (Zone): The forward zone to populate. 317 | 318 | Returns: 319 | List[Rr]: A list of Rr objects for A/AAAA records. 320 | """ 321 | filter_kwargs = self._build_forward_filter_kwargs(zone) 322 | ipam_records = self._nb_client.ipam.ip_addresses.filter(**filter_kwargs) 323 | 324 | return self._build_forward_records(zone, ipam_records) 325 | 326 | def _build_forward_filter_kwargs(self, zone: Zone) -> dict[str, typing.Any]: 327 | """ 328 | Builds the filter kwargs for NetBox queries when populating forward A/AAAA records. 329 | 330 | Args: 331 | zone (Zone): The forward zone for which to build query filters. 332 | 333 | Returns: 334 | dict[str, Any]: A dictionary of filter criteria for NetBox queries. 335 | """ 336 | zone_name_no_dot = zone.name.rstrip(".") 337 | filter_kwargs = { 338 | f"{self.field_name}__ic": zone_name_no_dot, 339 | "vrf_id": self.populate_vrf_id, 340 | "tag": self.populate_tags, 341 | } 342 | if filter_kwargs["vrf_id"] is None: 343 | del filter_kwargs["vrf_id"] 344 | return filter_kwargs 345 | 346 | def _build_forward_records( 347 | self, zone: Zone, ipam_records: typing.Iterable[typing.Any] 348 | ) -> typing.List[Rr]: 349 | """ 350 | Creates A/AAAA Rr objects from NetBox IP address records for the given zone. 351 | 352 | Args: 353 | zone (Zone): The forward zone being populated. 354 | ipam_records (Iterable[Any]): IPAM records returned by NetBox filter query. 355 | 356 | Returns: 357 | List[Rr]: A list of forward record objects to be added to the zone. 358 | """ 359 | records: typing.List[Rr] = [] 360 | for ipam_record in ipam_records: 361 | # Delegate the per-record processing to a helper method 362 | new_records = self._build_records_for_ipam_record(zone, ipam_record) 363 | records.extend(new_records) 364 | 365 | return records 366 | 367 | def _build_records_for_ipam_record( 368 | self, zone: Zone, ipam_record: typing.Any 369 | ) -> typing.List[Rr]: 370 | """ 371 | Converts a single NetBox IPAM record into one or more octoDNS record objects. 372 | 373 | Args: 374 | zone (Zone): The forward zone being populated. 375 | ipam_record (Any): A single IPAM record from NetBox. 376 | 377 | Returns: 378 | List[Rr]: A list of forward (A/AAAA) record objects corresponding 379 | to the provided IPAM record. 380 | """ 381 | records: typing.List[Rr] = [] 382 | 383 | # Derive IP address and record type 384 | ip_address = ip_interface(ipam_record.address).ip 385 | record_type: Literal["A", "AAAA"] = "A" if ip_address.version == 4 else "AAAA" 386 | 387 | # Parse out any FQDNs listed in the desired NetBox field 388 | fqdns = self._parse_fqdns_list(ipam_record[self.field_name]) 389 | 390 | # For each FQDN, determine if it belongs to this zone and create records 391 | for fqdn in fqdns: 392 | if not self._fqdn_in_zone(fqdn, zone): 393 | self.log.debug(f"Skip: FQDN={fqdn} on zone {zone.name}") 394 | continue 395 | 396 | name = zone.hostname_from_fqdn(fqdn) 397 | rr = Rr(name, record_type, self.ttl, ip_address.compressed) 398 | self.log.debug(f"Created {record_type} record {rr} for zone {zone.name}") 399 | records.append(rr) 400 | 401 | return records 402 | 403 | def _fqdn_in_zone(self, fqdn: str, zone: Zone) -> bool: 404 | """ 405 | Checks whether a given FQDN belongs in the specified zone. 406 | 407 | 1. If FQDN matches the zone's apex (fqdn == zone.name), it's valid. 408 | 2. If FQDN ends with ".", it's valid. However, if 409 | self.populate_subdomains is False, we exclude deeper subdomains. 410 | 3. Otherwise, it's invalid. 411 | 412 | Args: 413 | fqdn (str): The fully qualified domain name to check. 414 | zone (Zone): The zone against which to match. 415 | 416 | Returns: 417 | bool: True if the FQDN should be included in this zone, False otherwise. 418 | """ 419 | if fqdn == zone.name: 420 | return True 421 | 422 | if fqdn.endswith(f".{zone.name}"): 423 | leftover = fqdn[: -len(zone.name)].rstrip(".") 424 | # If not populating subdomains, exclude multi-level subdomains 425 | if self.populate_subdomains or "." not in leftover: 426 | return True 427 | 428 | return False 429 | 430 | def _add_records_to_zone( 431 | self, zone: Zone, rrs: typing.List[Rr], lenient: bool 432 | ) -> None: 433 | """ 434 | Adds Rr objects to the given zone, respecting lenient mode 435 | and subzone exceptions. 436 | 437 | Args: 438 | zone (Zone): The zone to which records will be added. 439 | rrs (List[Rr]): A list of Rr objects. 440 | lenient (bool): If True, subzone exceptions will not raise an error. 441 | """ 442 | for record in Record.from_rrs(zone, rrs, lenient=lenient): 443 | try: 444 | zone.add_record(record, lenient=lenient) 445 | except SubzoneRecordException: 446 | self.log.warning(f"Skipping subzone record: {record}") 447 | 448 | def _parse_fqdns_list( 449 | self, field_value: str, len_limit: typing.Optional[int] = None 450 | ) -> typing.List[str]: 451 | """ 452 | Parses a string containing one or more comma-separated FQDNs into a list. 453 | Ensures each FQDN ends with a trailing period. 454 | 455 | Args: 456 | field_value (str): The raw field containing comma-separated FQDNs. 457 | len_limit (Optional[int]): If set, limits how many FQDNs to parse. 458 | 459 | Returns: 460 | List[str]: A list of sanitized FQDNs, each ending with a period. 461 | """ 462 | fqdns = [ 463 | fqdn.strip() if fqdn.strip().endswith(".") else f"{fqdn.strip()}." 464 | for fqdn in field_value.split(",") 465 | if fqdn.strip() 466 | ] 467 | return fqdns[:len_limit] if len_limit else fqdns 468 | -------------------------------------------------------------------------------- /octodns_netbox/reversename.py: -------------------------------------------------------------------------------- 1 | import ipaddress 2 | import re 3 | from typing import Union 4 | 5 | from octodns.zone import Zone 6 | 7 | 8 | def to_network(zone: Zone) -> Union[ipaddress.IPv4Network, ipaddress.IPv6Network]: 9 | """ 10 | Convert a reverse DNS zone (either in-addr.arpa for IPv4 or ip6.arpa for IPv6) 11 | into its corresponding IPv4Network or IPv6Network object. 12 | 13 | Args: 14 | zone (Zone): The octoDNS Zone object representing a reverse zone. 15 | 16 | Returns: 17 | Union[ipaddress.IPv4Network, ipaddress.IPv6Network]: The inferred IP network. 18 | 19 | Raises: 20 | ValueError: If the zone name does not end with '.in-addr.arpa.' or '.ip6.arpa.'. 21 | """ 22 | if zone.name.endswith(".in-addr.arpa."): 23 | return to_network_v4(zone) 24 | elif zone.name.endswith(".ip6.arpa."): 25 | return to_network_v6(zone) 26 | else: 27 | raise ValueError( 28 | f"Invalid reverse zone '{zone.name}'. " 29 | "Zone must end with '.in-addr.arpa.' (IPv4) or '.ip6.arpa.' (IPv6)." 30 | ) 31 | 32 | 33 | def to_network_v4(zone: Zone) -> ipaddress.IPv4Network: 34 | """ 35 | Construct an IPv4 network definition from a reverse IPv4 zone name. 36 | 37 | This function supports blocks smaller than /24 (e.g., /30) as described in 38 | RFC 2317 (Classless IN-ADDR.ARPA delegation) and RFC 4183. It uses a regex to detect 39 | a non-octet boundary (e.g., '192/26') in the leftmost label if it includes a slash. 40 | 41 | Example: 42 | If the zone name is '192/26.168.10.in-addr.arpa.', then: 43 | - labels = ['192/26', '168', '10'] 44 | - netmask = 26 45 | - The resulting network might be '10.168.192.0/26'. 46 | 47 | Args: 48 | zone (Zone): The octoDNS Zone object for an IPv4 reverse zone, e.g. '192/26.168.10.in-addr.arpa.'. 49 | 50 | Returns: 51 | ipaddress.IPv4Network: The corresponding IPv4 network object. 52 | 53 | Raises: 54 | ValueError: If the leftmost label cannot be parsed as an IPv4 octet or slash-based netmask. 55 | """ 56 | labels = zone.name.split(".")[:-3] # remove the trailing ['in-addr', 'arpa', ''] 57 | netmask = 8 * len(labels) # default netmask based on how many labels we have 58 | offset = 4 - len( 59 | labels 60 | ) # how many octets we must prepend as '0' for a full 4-octet address 61 | 62 | # Regex to detect optional slash-based netmask (e.g. 192/26) 63 | pattern = r"^(25[0-5]|2[0-4]\d|[01]?\d?\d)([/-](2[5-9]|3[0-1]))?$" 64 | match = re.search(pattern, labels[0]) 65 | if not match: 66 | raise ValueError( 67 | f"Failed to parse the leftmost IPv4 label '{labels[0]}' in zone '{zone.name}' " 68 | "as an octet or slash-based netmask." 69 | ) 70 | 71 | # If we have a slash notation in the matched group, adjust the netmask 72 | if match[2]: 73 | # Example: '192/26' => '192', netmask=26 74 | last_octet = match[1] 75 | labels[0] = last_octet 76 | netmask = int(match[2][1:]) # remove the '/' or '-' then convert to int 77 | 78 | # Prepend "0" for the missing octets 79 | labels = ["0"] * offset + labels 80 | 81 | # Reverse labels to get standard x.x.x.x format, then apply netmask 82 | prefix_str = ".".join(reversed(labels)) 83 | prefix_str += f"/{netmask}" 84 | 85 | return ipaddress.IPv4Network(prefix_str, strict=True) 86 | 87 | 88 | def to_network_v6(zone: Zone) -> ipaddress.IPv6Network: 89 | """ 90 | Construct an IPv6 network definition from a reverse IPv6 zone name. 91 | 92 | For an IPv6 reverse zone (ip6.arpa.), each label typically represents one nibble (hex digit). 93 | This function reverses the nibble labels, groups them into 4-hex-digit blocks (hextets), 94 | and appends an appropriate prefix length based on the number of nibbles. 95 | 96 | If the total number of nibbles is not a multiple of 4, zeroes are appended to 97 | make it a multiple of 4 before grouping into hextets. 98 | 99 | Example: 100 | If the zone name is 'b.a.8.f.ip6.arpa.', that implies the reversed nibble string 'f8ab'. 101 | It then forms 'f8ab::/16' if there were exactly 4 nibbles total. 102 | 103 | Args: 104 | zone (Zone): The octoDNS Zone object for an IPv6 reverse zone, e.g. 'b.a.8.f.ip6.arpa.'. 105 | 106 | Returns: 107 | ipaddress.IPv6Network: The corresponding IPv6 network object. 108 | """ 109 | labels = zone.name.split(".")[:-3] # remove ['ip6', 'arpa', ''] 110 | # Reverse the nibble labels to get the forward nibble string 111 | reversed_nibbles = "".join(reversed(labels)) 112 | 113 | # Pad to a multiple of 4 characters (each group of 4 is one hextet) 114 | remainder = len(reversed_nibbles) % 4 115 | if remainder != 0: 116 | reversed_nibbles += "0" * (4 - remainder) 117 | 118 | # Split into hextets 119 | hextets = [reversed_nibbles[i : i + 4] for i in range(0, len(reversed_nibbles), 4)] 120 | prefix_str = ":".join(hextets) 121 | # Each label was 1 nibble -> each label = 4 bits => total prefix length = len(labels) * 4 122 | prefix_str += f"::/{len(labels) * 4}" 123 | 124 | return ipaddress.IPv6Network(prefix_str, strict=True) 125 | 126 | 127 | def from_address( 128 | zone: Zone, ip_address: Union[ipaddress.IPv4Address, ipaddress.IPv6Address] 129 | ) -> str: 130 | """ 131 | Given a reverse zone and an IP address, construct the full reverse pointer (FQDN) string, 132 | ensuring it aligns with the zone name. If the standard reverse pointer (via ip_address.reverse_pointer) 133 | does not already contain the zone name, we attempt to merge them. 134 | 135 | This is used to generate a reverse FQDN from an ip_address that matches the zone definition. 136 | 137 | Args: 138 | zone (Zone): A zone object, presumably ending in '.in-addr.arpa.' or '.ip6.arpa.'. 139 | ip_address (Union[ipaddress.IPv4Address, ipaddress.IPv6Address]): An IP address object. 140 | 141 | Returns: 142 | str: The reverse FQDN (e.g. '1.0.0.127.in-addr.arpa.') that corresponds to the zone. 143 | 144 | Raises: 145 | ValueError: If the zone name is not a valid IPv4 or IPv6 reverse zone. 146 | """ 147 | if not zone.name.endswith((".in-addr.arpa.", ".ip6.arpa.")): 148 | raise ValueError( 149 | f"Invalid reverse zone '{zone.name}'. " 150 | "Zone must end with '.in-addr.arpa.' (IPv4) or '.ip6.arpa.' (IPv6)." 151 | ) 152 | 153 | # ip_address.reverse_pointer returns something like '1.0.0.127.in-addr.arpa' for 127.0.0.1 154 | fqdn = f"{ip_address.reverse_pointer}." 155 | 156 | # If the zone name is already in the FQDN, no extra fix-up needed 157 | if zone.name in fqdn: 158 | return fqdn 159 | 160 | # Otherwise, we try to merge the zone labels with the standard reverse pointer 161 | zone_labels = zone.name.split(".") 162 | standard_labels = fqdn.split(".") 163 | 164 | # Insert the standard label segments in front of the zone labels 165 | # to ensure the final FQDN merges both sets 166 | for i in range(len(zone_labels) - len(standard_labels) + 1): 167 | zone_labels.insert(0, standard_labels[i]) 168 | 169 | return ".".join(zone_labels) 170 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "poetry_dynamic_versioning.backend" 3 | requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] 4 | 5 | [project] 6 | authors = [ 7 | {name = "Tagawa, Masaki", email = "masaki@tagawa.email"} 8 | ] 9 | classifiers = [ 10 | 'Development Status :: 4 - Beta', 11 | 'Intended Audience :: Developers', 12 | 'License :: OSI Approved :: MIT License', 13 | 'Natural Language :: English', 14 | 'Programming Language :: Python :: 3', 15 | 'Programming Language :: Python :: 3.9', 16 | 'Programming Language :: Python :: 3.10', 17 | 'Programming Language :: Python :: 3.11', 18 | 'Programming Language :: Python :: 3.12', 19 | 'Programming Language :: Python :: 3.13' 20 | ] 21 | dependencies = [ 22 | "octodns (>=1.10.0,<2.0.0)", 23 | "pydantic (>=2.10.5,<3.0.0)", 24 | "pynetbox (>=7.4.1,<8.0.0)", 25 | "requests (>=2.32.3,<3.0.0)" 26 | ] 27 | description = "A NetBox source for octoDNS." 28 | dynamic = ["version"] 29 | homepage = "https://github.com/sukiyaki/octodns-netbox" 30 | license = "MIT" 31 | name = "octodns-netbox" 32 | readme = "README.md" 33 | requires-python = ">=3.9,<4" 34 | 35 | [tool.poetry] 36 | version = "0.0.0" 37 | 38 | [tool.poetry.group.dev.dependencies] 39 | pre-commit = "^4.0.0" 40 | pytest = "^8.0.0" 41 | pytest-cov = "^6.0.0" 42 | requests-mock = "^1.12.0" 43 | tox = "^4.0.0" 44 | 45 | [tool.poetry.requires-plugins] 46 | poetry-dynamic-versioning = {version = ">=1.0.0,<2.0.0", extras = ["plugin"]} 47 | 48 | [tool.poetry-dynamic-versioning] 49 | bump = true 50 | enable = true 51 | metadata = false 52 | style = "pep440" 53 | vcs = "git" 54 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ], 6 | "pre-commit": { 7 | "enabled": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sukiyaki/octodns-netbox/5f03a8434e463247ef0c3d8c57f9e36fdcd72c01/tests/__init__.py -------------------------------------------------------------------------------- /tests/fixtures/ip_addresses_example_com.json: -------------------------------------------------------------------------------- 1 | { 2 | "count": 6, 3 | "next": null, 4 | "previous": null, 5 | "results": [ 6 | { 7 | "id": 1810, 8 | "url": "https://ipam.example.com/api/ipam/ip-addresses/1810/", 9 | "display": "192.0.4.1/24", 10 | "family": { 11 | "value": 4, 12 | "label": "IPv4" 13 | }, 14 | "address": "192.0.4.1/24", 15 | "vrf": null, 16 | "tenant": null, 17 | "status": { 18 | "value": "active", 19 | "label": "Active" 20 | }, 21 | "role": null, 22 | "assigned_object_type": null, 23 | "assigned_object_id": null, 24 | "assigned_object": null, 25 | "nat_inside": null, 26 | "nat_outside": [], 27 | "dns_name": "dnsname-host1.example.com", 28 | "description": "description-host1.example.com", 29 | "tags": [], 30 | "custom_fields": {}, 31 | "created": "2022-09-24T06:30:46.724586Z", 32 | "last_updated": "2022-09-24T06:38:44.889092Z" 33 | }, 34 | { 35 | "id": 1590, 36 | "url": "https://ipam.example.com/api/ipam/ip-addresses/1590/", 37 | "display": "192.0.4.2/24", 38 | "family": { 39 | "value": 4, 40 | "label": "IPv4" 41 | }, 42 | "address": "192.0.4.2/24", 43 | "vrf": null, 44 | "tenant": null, 45 | "status": { 46 | "value": "active", 47 | "label": "Active" 48 | }, 49 | "role": null, 50 | "assigned_object_type": null, 51 | "assigned_object_id": null, 52 | "assigned_object": null, 53 | "nat_inside": null, 54 | "nat_outside": [], 55 | "dns_name": "dnsname-host2.subdomain1.example.com", 56 | "description": "description-host2.subdomain1.example.com,description-host2.subdomain2.example.com", 57 | "tags": [], 58 | "custom_fields": {}, 59 | "created": "2022-09-11T00:00:00Z", 60 | "last_updated": "2022-09-17T00:44:23.809693Z" 61 | }, 62 | { 63 | "id": 1591, 64 | "url": "https://ipam.example.com/api/ipam/ip-addresses/1591/", 65 | "display": "192.0.4.3/24", 66 | "family": { 67 | "value": 4, 68 | "label": "IPv4" 69 | }, 70 | "address": "192.0.4.3/24", 71 | "vrf": null, 72 | "tenant": null, 73 | "status": { 74 | "value": "active", 75 | "label": "Active" 76 | }, 77 | "role": null, 78 | "assigned_object_type": null, 79 | "assigned_object_id": null, 80 | "assigned_object": null, 81 | "nat_inside": null, 82 | "nat_outside": [], 83 | "dns_name": "dnsname-host3.subdomain1.example.com.jp", 84 | "description": "description-host3.subdomain1.example.com.jp,description-host3.subdomain2.example.com", 85 | "tags": [], 86 | "custom_fields": {}, 87 | "created": "2022-09-11T00:00:00Z", 88 | "last_updated": "2022-09-17T00:44:42.331660Z" 89 | }, 90 | { 91 | "id": 1592, 92 | "url": "https://ipam.example.com/api/ipam/ip-addresses/1592/", 93 | "display": "192.0.4.4/24", 94 | "family": { 95 | "value": 4, 96 | "label": "IPv4" 97 | }, 98 | "address": "192.0.4.4/24", 99 | "vrf": null, 100 | "tenant": null, 101 | "status": { 102 | "value": "active", 103 | "label": "Active" 104 | }, 105 | "role": null, 106 | "assigned_object_type": null, 107 | "assigned_object_id": null, 108 | "assigned_object": null, 109 | "nat_inside": null, 110 | "nat_outside": [], 111 | "dns_name": "subdomain1.example.com", 112 | "description": "dns_name is exactly the same as the sub domain so this record should be rejected", 113 | "tags": [], 114 | "custom_fields": {}, 115 | "created": "2022-09-11T00:00:00Z", 116 | "last_updated": "2022-09-17T00:44:42.331660Z" 117 | }, 118 | { 119 | "id": 1593, 120 | "url": "https://ipam.example.com/api/ipam/ip-addresses/1593/", 121 | "display": "192.0.4.5/24", 122 | "family": { 123 | "value": 4, 124 | "label": "IPv4" 125 | }, 126 | "address": "192.0.4.5/24", 127 | "vrf": null, 128 | "tenant": null, 129 | "status": { 130 | "value": "active", 131 | "label": "Active" 132 | }, 133 | "role": null, 134 | "assigned_object_type": null, 135 | "assigned_object_id": null, 136 | "assigned_object": null, 137 | "nat_inside": null, 138 | "nat_outside": [], 139 | "dns_name": "dnsname-roundrobin.example.com", 140 | "description": "description-roundrobin.example.com", 141 | "tags": [], 142 | "custom_fields": {}, 143 | "created": "2022-09-11T00:00:00Z", 144 | "last_updated": "2022-09-17T00:44:42.331660Z" 145 | }, 146 | { 147 | "id": 1594, 148 | "url": "https://ipam.example.com/api/ipam/ip-addresses/1594/", 149 | "display": "192.0.4.6/24", 150 | "family": { 151 | "value": 4, 152 | "label": "IPv4" 153 | }, 154 | "address": "192.0.4.6/24", 155 | "vrf": null, 156 | "tenant": null, 157 | "status": { 158 | "value": "active", 159 | "label": "Active" 160 | }, 161 | "role": null, 162 | "assigned_object_type": null, 163 | "assigned_object_id": null, 164 | "assigned_object": null, 165 | "nat_inside": null, 166 | "nat_outside": [], 167 | "dns_name": "dnsname-roundrobin.example.com", 168 | "description": "description-roundrobin.example.com", 169 | "tags": [], 170 | "custom_fields": {}, 171 | "created": "2022-09-11T00:00:00Z", 172 | "last_updated": "2022-09-17T00:44:42.331660Z" 173 | }, 174 | { 175 | "id": 1781, 176 | "url": "https://ipam.example.com/api/ipam/ip-addresses/1781/", 177 | "display": "2001:0db8::1:1/64", 178 | "family": { 179 | "value": 6, 180 | "label": "IPv6" 181 | }, 182 | "address": "2001:0db8::1:1/64", 183 | "vrf": null, 184 | "tenant": null, 185 | "status": { 186 | "value": "active", 187 | "label": "Active" 188 | }, 189 | "role": null, 190 | "assigned_object_type": null, 191 | "assigned_object_id": null, 192 | "assigned_object": null, 193 | "nat_inside": null, 194 | "nat_outside": [], 195 | "dns_name": "dnsname-host1.example.com", 196 | "description": "description-host1.example.com", 197 | "tags": [], 198 | "custom_fields": {}, 199 | "created": "2022-09-21T07:41:47.745368Z", 200 | "last_updated": "2022-09-21T07:41:47.745403Z" 201 | }, 202 | { 203 | "id": 1783, 204 | "url": "https://ipam.example.com/api/ipam/ip-addresses/1783/", 205 | "display": "2001:0db8::1:2/64", 206 | "family": { 207 | "value": 6, 208 | "label": "IPv6" 209 | }, 210 | "address": "2001:0db8::1:2/64", 211 | "vrf": null, 212 | "tenant": null, 213 | "status": { 214 | "value": "active", 215 | "label": "Active" 216 | }, 217 | "role": null, 218 | "assigned_object_type": null, 219 | "assigned_object_id": null, 220 | "assigned_object": null, 221 | "nat_inside": null, 222 | "nat_outside": [], 223 | "dns_name": "dnsname-host2.subdomain1.example.com", 224 | "description": "description-host2.subdomain1.example.com,description-host2.subdomain2.example.com", 225 | "tags": [], 226 | "custom_fields": {}, 227 | "created": "2022-09-21T07:41:47.800886Z", 228 | "last_updated": "2022-09-21T07:41:47.800910Z" 229 | }, 230 | { 231 | "id": 1603, 232 | "url": "https://ipam.example.com/api/ipam/ip-addresses/1603/", 233 | "display": "2001:0db8::1:3/64", 234 | "family": { 235 | "value": 6, 236 | "label": "IPv6" 237 | }, 238 | "address": "2001:0db8::1:3/64", 239 | "vrf": null, 240 | "tenant": null, 241 | "status": { 242 | "value": "active", 243 | "label": "Active" 244 | }, 245 | "role": null, 246 | "assigned_object_type": null, 247 | "assigned_object_id": null, 248 | "assigned_object": null, 249 | "nat_inside": null, 250 | "nat_outside": [], 251 | "dns_name": "dnsname-host3.subdomain1.example.com.jp", 252 | "description": "description-host3.subdomain1.example.com.jp,description-host3.subdomain2.example.com", 253 | "tags": [], 254 | "custom_fields": {}, 255 | "created": "2022-09-11T00:00:00Z", 256 | "last_updated": "2022-09-11T06:12:26.757766Z" 257 | }, 258 | { 259 | "id": 1784, 260 | "url": "https://ipam.example.com/api/ipam/ip-addresses/1784/", 261 | "display": "2001:0db8::1:4/64", 262 | "family": { 263 | "value": 6, 264 | "label": "IPv6" 265 | }, 266 | "address": "2001:0db8::1:4/64", 267 | "vrf": null, 268 | "tenant": null, 269 | "status": { 270 | "value": "active", 271 | "label": "Active" 272 | }, 273 | "role": null, 274 | "assigned_object_type": null, 275 | "assigned_object_id": null, 276 | "assigned_object": null, 277 | "nat_inside": null, 278 | "nat_outside": [], 279 | "dns_name": "dnsname-subdomain1.example.com", 280 | "description": "description-subdomain1.example.com", 281 | "tags": [], 282 | "custom_fields": {}, 283 | "created": "2022-09-21T07:41:47.800886Z", 284 | "last_updated": "2022-09-21T07:41:47.800910Z" 285 | }, 286 | { 287 | "id": 1604, 288 | "url": "https://ipam.example.com/api/ipam/ip-addresses/1604/", 289 | "display": "2001:0db8::1:5/64", 290 | "family": { 291 | "value": 6, 292 | "label": "IPv6" 293 | }, 294 | "address": "2001:0db8::1:5/64", 295 | "vrf": null, 296 | "tenant": null, 297 | "status": { 298 | "value": "active", 299 | "label": "Active" 300 | }, 301 | "role": null, 302 | "assigned_object_type": null, 303 | "assigned_object_id": null, 304 | "assigned_object": null, 305 | "nat_inside": null, 306 | "nat_outside": [], 307 | "dns_name": "dnsname-roundrobin.example.com", 308 | "description": "description-roundrobin.example.com", 309 | "tags": [], 310 | "custom_fields": {}, 311 | "created": "2022-09-11T00:00:00Z", 312 | "last_updated": "2022-09-11T06:12:26.757766Z" 313 | }, 314 | { 315 | "id": 1605, 316 | "url": "https://ipam.example.com/api/ipam/ip-addresses/1605/", 317 | "display": "2001:0db8::1:6/64", 318 | "family": { 319 | "value": 6, 320 | "label": "IPv6" 321 | }, 322 | "address": "2001:0db8::1:6/64", 323 | "vrf": null, 324 | "tenant": null, 325 | "status": { 326 | "value": "active", 327 | "label": "Active" 328 | }, 329 | "role": null, 330 | "assigned_object_type": null, 331 | "assigned_object_id": null, 332 | "assigned_object": null, 333 | "nat_inside": null, 334 | "nat_outside": [], 335 | "dns_name": "dnsname-roundrobin.example.com", 336 | "description": "description-roundrobin.example.com", 337 | "tags": [], 338 | "custom_fields": {}, 339 | "created": "2022-09-11T00:00:00Z", 340 | "last_updated": "2022-09-11T06:12:26.757766Z" 341 | }, 342 | { 343 | "id": 1606, 344 | "url": "https://ipam.example.com/api/ipam/ip-addresses/1606/", 345 | "display": "2001:0db8::1:7/64", 346 | "family": { 347 | "value": 6, 348 | "label": "IPv6" 349 | }, 350 | "address": "2001:0db8::1:7/64", 351 | "vrf": null, 352 | "tenant": null, 353 | "status": { 354 | "value": "active", 355 | "label": "Active" 356 | }, 357 | "role": null, 358 | "assigned_object_type": null, 359 | "assigned_object_id": null, 360 | "assigned_object": null, 361 | "nat_inside": null, 362 | "nat_outside": [], 363 | "dns_name": "example.com", 364 | "description": "example.com", 365 | "tags": [], 366 | "custom_fields": {}, 367 | "created": "2022-09-11T00:00:00Z", 368 | "last_updated": "2022-09-11T06:12:26.757766Z" 369 | } 370 | ] 371 | } 372 | -------------------------------------------------------------------------------- /tests/fixtures/ip_addresses_subdomain1_example_com.json: -------------------------------------------------------------------------------- 1 | { 2 | "count": 6, 3 | "next": null, 4 | "previous": null, 5 | "results": [ 6 | { 7 | "id": 1590, 8 | "url": "https://ipam.example.com/api/ipam/ip-addresses/1590/", 9 | "display": "192.0.4.2/24", 10 | "family": { 11 | "value": 4, 12 | "label": "IPv4" 13 | }, 14 | "address": "192.0.4.2/24", 15 | "vrf": null, 16 | "tenant": null, 17 | "status": { 18 | "value": "active", 19 | "label": "Active" 20 | }, 21 | "role": null, 22 | "assigned_object_type": null, 23 | "assigned_object_id": null, 24 | "assigned_object": null, 25 | "nat_inside": null, 26 | "nat_outside": [], 27 | "dns_name": "dnsname-host2.subdomain1.example.com", 28 | "description": "description-host2.subdomain1.example.com,description-host2.subdomain2.example.com", 29 | "tags": [], 30 | "custom_fields": {}, 31 | "created": "2022-09-11T00:00:00Z", 32 | "last_updated": "2022-09-17T00:44:23.809693Z" 33 | }, 34 | { 35 | "id": 1591, 36 | "url": "https://ipam.example.com/api/ipam/ip-addresses/1591/", 37 | "display": "192.0.4.3/24", 38 | "family": { 39 | "value": 4, 40 | "label": "IPv4" 41 | }, 42 | "address": "192.0.4.3/24", 43 | "vrf": null, 44 | "tenant": null, 45 | "status": { 46 | "value": "active", 47 | "label": "Active" 48 | }, 49 | "role": null, 50 | "assigned_object_type": null, 51 | "assigned_object_id": null, 52 | "assigned_object": null, 53 | "nat_inside": null, 54 | "nat_outside": [], 55 | "dns_name": "dnsname-host3.subdomain1.example.com.jp", 56 | "description": "description-host3.subdomain1.example.com.jp,description-host3.subdomain2.example.com", 57 | "tags": [], 58 | "custom_fields": {}, 59 | "created": "2022-09-11T00:00:00Z", 60 | "last_updated": "2022-09-17T00:44:42.331660Z" 61 | }, 62 | { 63 | "id": 1592, 64 | "url": "https://ipam.example.com/api/ipam/ip-addresses/1592/", 65 | "display": "192.0.4.4/24", 66 | "family": { 67 | "value": 4, 68 | "label": "IPv4" 69 | }, 70 | "address": "192.0.4.4/24", 71 | "vrf": null, 72 | "tenant": null, 73 | "status": { 74 | "value": "active", 75 | "label": "Active" 76 | }, 77 | "role": null, 78 | "assigned_object_type": null, 79 | "assigned_object_id": null, 80 | "assigned_object": null, 81 | "nat_inside": null, 82 | "nat_outside": [], 83 | "dns_name": "subdomain1.example.com", 84 | "description": "subdomain1.example.com", 85 | "tags": [], 86 | "custom_fields": {}, 87 | "created": "2022-09-11T00:00:00Z", 88 | "last_updated": "2022-09-17T00:44:42.331660Z" 89 | }, 90 | { 91 | "id": 1783, 92 | "url": "https://ipam.example.com/api/ipam/ip-addresses/1783/", 93 | "display": "2001:0db8::1:2/64", 94 | "family": { 95 | "value": 6, 96 | "label": "IPv6" 97 | }, 98 | "address": "2001:0db8::1:2/64", 99 | "vrf": null, 100 | "tenant": null, 101 | "status": { 102 | "value": "active", 103 | "label": "Active" 104 | }, 105 | "role": null, 106 | "assigned_object_type": null, 107 | "assigned_object_id": null, 108 | "assigned_object": null, 109 | "nat_inside": null, 110 | "nat_outside": [], 111 | "dns_name": "dnsname-host2.subdomain1.example.com", 112 | "description": "description-host2.subdomain1.example.com,description-host2.subdomain2.example.com", 113 | "tags": [], 114 | "custom_fields": {}, 115 | "created": "2022-09-21T07:41:47.800886Z", 116 | "last_updated": "2022-09-21T07:41:47.800910Z" 117 | }, 118 | { 119 | "id": 1603, 120 | "url": "https://ipam.example.com/api/ipam/ip-addresses/1603/", 121 | "display": "2001:0db8::1:3/64", 122 | "family": { 123 | "value": 6, 124 | "label": "IPv6" 125 | }, 126 | "address": "2001:0db8::1:3/64", 127 | "vrf": null, 128 | "tenant": null, 129 | "status": { 130 | "value": "active", 131 | "label": "Active" 132 | }, 133 | "role": null, 134 | "assigned_object_type": null, 135 | "assigned_object_id": null, 136 | "assigned_object": null, 137 | "nat_inside": null, 138 | "nat_outside": [], 139 | "dns_name": "dnsname-host3.subdomain1.example.com.jp", 140 | "description": "description-host3.subdomain1.example.com.jp,description-host3.subdomain2.example.com", 141 | "tags": [], 142 | "custom_fields": {}, 143 | "created": "2022-09-11T00:00:00Z", 144 | "last_updated": "2022-09-11T06:12:26.757766Z" 145 | }, 146 | { 147 | "id": 1784, 148 | "url": "https://ipam.example.com/api/ipam/ip-addresses/1784/", 149 | "display": "2001:0db8::1:4/64", 150 | "family": { 151 | "value": 6, 152 | "label": "IPv6" 153 | }, 154 | "address": "2001:0db8::1:4/64", 155 | "vrf": null, 156 | "tenant": null, 157 | "status": { 158 | "value": "active", 159 | "label": "Active" 160 | }, 161 | "role": null, 162 | "assigned_object_type": null, 163 | "assigned_object_id": null, 164 | "assigned_object": null, 165 | "nat_inside": null, 166 | "nat_outside": [], 167 | "dns_name": "dnsname-subdomain1.example.com", 168 | "description": "description-subdomain1.example.com", 169 | "tags": [], 170 | "custom_fields": {}, 171 | "created": "2022-09-21T07:41:47.800886Z", 172 | "last_updated": "2022-09-21T07:41:47.800910Z" 173 | } 174 | ] 175 | } 176 | -------------------------------------------------------------------------------- /tests/fixtures/ip_addresses_v4_non_octet_boundary.json: -------------------------------------------------------------------------------- 1 | { 2 | "count": 3, 3 | "next": null, 4 | "previous": null, 5 | "results": [ 6 | { 7 | "id": 8, 8 | "url": "https://ipam.example.com/api/ipam/ip-addresses/8/", 9 | "display": "192.0.2.1/29", 10 | "family": { 11 | "value": 4, 12 | "label": "IPv4" 13 | }, 14 | "address": "192.0.2.1/29", 15 | "vrf": null, 16 | "tenant": null, 17 | "status": { 18 | "value": "reserved", 19 | "label": "Reserved" 20 | }, 21 | "role": null, 22 | "assigned_object_type": "dcim.interface", 23 | "assigned_object_id": null, 24 | "assigned_object": null, 25 | "nat_inside": null, 26 | "nat_outside": [], 27 | "dns_name": "dnsname-192-0-2-1.example.com", 28 | "description": "description-192-0-2-1.example.com", 29 | "tags": [], 30 | "custom_fields": {}, 31 | "created": "2020-02-21T00:00:00Z", 32 | "last_updated": "2020-02-28T05:32:18.615510Z" 33 | }, 34 | { 35 | "id": 9, 36 | "url": "https://ipam.example.com/api/ipam/ip-addresses/9/", 37 | "display": "192.0.2.2/29", 38 | "family": null, 39 | "address": "192.0.2.2/29", 40 | "vrf": null, 41 | "tenant": null, 42 | "status": { 43 | "value": "reserved", 44 | "label": "Reserved" 45 | }, 46 | "role": null, 47 | "assigned_object_type": "dcim.interface", 48 | "assigned_object_id": null, 49 | "assigned_object": null, 50 | "nat_inside": null, 51 | "nat_outside": [], 52 | "dns_name": "dnsname-192-0-2-2.example.com", 53 | "description": "description-192-0-2-2.example.com", 54 | "tags": [], 55 | "custom_fields": {}, 56 | "created": "2020-02-21T00:00:00Z", 57 | "last_updated": "2020-02-28T05:32:19.699043Z" 58 | }, 59 | { 60 | "id": 10, 61 | "url": "https://ipam.example.com/api/ipam/ip-addresses/10/", 62 | "display": "192.0.2.3/29", 63 | "family": { 64 | "value": 4, 65 | "label": "IPv4" 66 | }, 67 | "address": "192.0.2.3/29", 68 | "vrf": null, 69 | "tenant": null, 70 | "status": { 71 | "value": "reserved", 72 | "label": "Reserved" 73 | }, 74 | "role": null, 75 | "assigned_object_type": "dcim.interface", 76 | "assigned_object_id": null, 77 | "assigned_object": null, 78 | "nat_inside": null, 79 | "nat_outside": [], 80 | "dns_name": "dnsname-192-0-2-3.example.com", 81 | "description": "description-192-0-2-3.example.com", 82 | "tags": [], 83 | "custom_fields": {}, 84 | "created": "2020-02-21T00:00:00Z", 85 | "last_updated": "2020-02-28T05:32:20.708105Z" 86 | } 87 | ] 88 | } 89 | -------------------------------------------------------------------------------- /tests/fixtures/ip_addresses_v4_non_octet_boundary_vrf_mgmt.json: -------------------------------------------------------------------------------- 1 | { 2 | "count": 3, 3 | "next": null, 4 | "previous": null, 5 | "results": [ 6 | { 7 | "id": 11, 8 | "url": "https://ipam.example.com/api/ipam/ip-addresses/11/", 9 | "display": "192.0.2.1/29", 10 | "family": { 11 | "value": 4, 12 | "label": "IPv4" 13 | }, 14 | "address": "192.0.2.1/29", 15 | "vrf": { 16 | "id": 1, 17 | "url": "https://ipam.example.com/api/ipam/vrfs/1/", 18 | "display": "mgmt", 19 | "name": "mgmt", 20 | "rd": null 21 | }, 22 | "tenant": null, 23 | "status": { 24 | "value": "reserved", 25 | "label": "Reserved" 26 | }, 27 | "role": null, 28 | "assigned_object_type": "dcim.interface", 29 | "assigned_object_id": null, 30 | "assigned_object": null, 31 | "nat_inside": null, 32 | "nat_outside": [], 33 | "dns_name": "vrf-mgmt-dnsname-192-0-2-1.example.com", 34 | "description": "vrf-mgmt-description-192-0-2-1.example.com", 35 | "tags": [], 36 | "custom_fields": {}, 37 | "created": "2020-02-21T00:00:00Z", 38 | "last_updated": "2020-02-28T05:32:18.615510Z" 39 | }, 40 | { 41 | "id": 12, 42 | "url": "https://ipam.example.com/api/ipam/ip-addresses/12/", 43 | "display": "192.0.2.2/29", 44 | "family": null, 45 | "address": "192.0.2.2/29", 46 | "vrf": { 47 | "id": 1, 48 | "url": "https://ipam.example.com/api/ipam/vrfs/1/", 49 | "display": "mgmt", 50 | "name": "mgmt", 51 | "rd": null 52 | }, 53 | "tenant": null, 54 | "status": { 55 | "value": "reserved", 56 | "label": "Reserved" 57 | }, 58 | "role": null, 59 | "assigned_object_type": "dcim.interface", 60 | "assigned_object_id": null, 61 | "assigned_object": null, 62 | "nat_inside": null, 63 | "nat_outside": [], 64 | "dns_name": "vrf-mgmt-dnsname-192-0-2-2.example.com", 65 | "description": "vrf-mgmt-description-192-0-2-2.example.com", 66 | "tags": [], 67 | "custom_fields": {}, 68 | "created": "2020-02-21T00:00:00Z", 69 | "last_updated": "2020-02-28T05:32:19.699043Z" 70 | }, 71 | { 72 | "id": 13, 73 | "url": "https://ipam.example.com/api/ipam/ip-addresses/13/", 74 | "display": "192.0.2.3/29", 75 | "family": { 76 | "value": 4, 77 | "label": "IPv4" 78 | }, 79 | "address": "192.0.2.3/29", 80 | "vrf": { 81 | "id": 1, 82 | "url": "https://ipam.example.com/api/ipam/vrfs/1/", 83 | "display": "mgmt", 84 | "name": "mgmt", 85 | "rd": null 86 | }, 87 | "tenant": null, 88 | "status": { 89 | "value": "reserved", 90 | "label": "Reserved" 91 | }, 92 | "role": null, 93 | "assigned_object_type": "dcim.interface", 94 | "assigned_object_id": null, 95 | "assigned_object": null, 96 | "nat_inside": null, 97 | "nat_outside": [], 98 | "dns_name": "vrf-mgmt-dnsname-192-0-2-3.example.com", 99 | "description": "vrf-mgmt-description-192-0-2-3.example.com", 100 | "tags": [], 101 | "custom_fields": {}, 102 | "created": "2020-02-21T00:00:00Z", 103 | "last_updated": "2020-02-28T05:32:20.708105Z" 104 | } 105 | ] 106 | } 107 | -------------------------------------------------------------------------------- /tests/fixtures/ip_addresses_v4_octet_boundary.json: -------------------------------------------------------------------------------- 1 | { 2 | "count": 3, 3 | "next": null, 4 | "previous": null, 5 | "results": [ 6 | { 7 | "id": 8, 8 | "url": "https://ipam.example.com/api/ipam/ip-addresses/8/", 9 | "display": "192.0.2.1/24", 10 | "family": { 11 | "value": 4, 12 | "label": "IPv4" 13 | }, 14 | "address": "192.0.2.1/24", 15 | "vrf": null, 16 | "tenant": null, 17 | "status": { 18 | "value": "reserved", 19 | "label": "Reserved" 20 | }, 21 | "role": null, 22 | "assigned_object_type": "dcim.interface", 23 | "assigned_object_id": null, 24 | "assigned_object": null, 25 | "nat_inside": null, 26 | "nat_outside": [], 27 | "dns_name": "dnsname-192-0-2-1.example.com", 28 | "description": "description-192-0-2-1.example.com", 29 | "tags": [], 30 | "custom_fields": {}, 31 | "created": "2020-02-21T00:00:00Z", 32 | "last_updated": "2020-02-28T05:32:18.615510Z" 33 | }, 34 | { 35 | "id": 9, 36 | "url": "https://ipam.example.com/api/ipam/ip-addresses/9/", 37 | "display": "192.0.2.2/24", 38 | "family": null, 39 | "address": "192.0.2.2/24", 40 | "vrf": null, 41 | "tenant": null, 42 | "status": { 43 | "value": "reserved", 44 | "label": "Reserved" 45 | }, 46 | "role": null, 47 | "assigned_object_type": "dcim.interface", 48 | "assigned_object_id": null, 49 | "assigned_object": null, 50 | "nat_inside": null, 51 | "nat_outside": [], 52 | "dns_name": "dnsname-192-0-2-2.example.com", 53 | "description": "description-192-0-2-2.example.com,description-multiptr-192-0-2-2.example.com", 54 | "tags": [], 55 | "custom_fields": {}, 56 | "created": "2020-02-21T00:00:00Z", 57 | "last_updated": "2020-02-28T05:32:19.699043Z" 58 | } 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /tests/fixtures/ip_addresses_v4_octet_boundary_vrf_global.json: -------------------------------------------------------------------------------- 1 | { 2 | "count": 3, 3 | "next": null, 4 | "previous": null, 5 | "results": [ 6 | { 7 | "id": 8, 8 | "url": "https://ipam.example.com/api/ipam/ip-addresses/8/", 9 | "display": "192.0.3.1/24", 10 | "family": { 11 | "value": 4, 12 | "label": "IPv4" 13 | }, 14 | "address": "192.0.3.1/24", 15 | "vrf": null, 16 | "tenant": null, 17 | "status": { 18 | "value": "reserved", 19 | "label": "Reserved" 20 | }, 21 | "role": null, 22 | "assigned_object_type": "dcim.interface", 23 | "assigned_object_id": null, 24 | "assigned_object": null, 25 | "nat_inside": null, 26 | "nat_outside": [], 27 | "dns_name": "dnsname-192-0-3-1.example.com", 28 | "description": "description-192-0-3-1.example.com", 29 | "tags": [], 30 | "custom_fields": {}, 31 | "created": "2020-02-21T00:00:00Z", 32 | "last_updated": "2020-02-28T05:32:18.615510Z" 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /tests/fixtures/ip_addresses_v6_nibble_boundary.json: -------------------------------------------------------------------------------- 1 | { 2 | "count": 2, 3 | "next": null, 4 | "previous": null, 5 | "results": [ 6 | { 7 | "id": 1774, 8 | "url": "https://ipam.example.com/api/ipam/ip-addresses/1774/", 9 | "display": "2001:0db8::2/64", 10 | "family": { 11 | "value": 6, 12 | "label": "IPv6" 13 | }, 14 | "address": "2001:0db8::2/64", 15 | "vrf": null, 16 | "tenant": null, 17 | "status": { 18 | "value": "active", 19 | "label": "Active" 20 | }, 21 | "role": null, 22 | "assigned_object_type": null, 23 | "assigned_object_id": null, 24 | "assigned_object": null, 25 | "nat_inside": null, 26 | "nat_outside": [], 27 | "dns_name": "dnsname-2001-0db8-2.example.com", 28 | "description": "description-2001-0db8-2.example.com", 29 | "tags": [], 30 | "custom_fields": {}, 31 | "created": "2022-09-21T07:41:47.545639Z", 32 | "last_updated": "2022-09-21T07:41:47.545662Z" 33 | }, 34 | { 35 | "id": 1775, 36 | "url": "https://ipam.example.com/api/ipam/ip-addresses/1775/", 37 | "display": "2001:0db8::3/64", 38 | "family": { 39 | "value": 6, 40 | "label": "IPv6" 41 | }, 42 | "address": "2001:0db8::3/64", 43 | "vrf": null, 44 | "tenant": null, 45 | "status": { 46 | "value": "active", 47 | "label": "Active" 48 | }, 49 | "role": null, 50 | "assigned_object_type": null, 51 | "assigned_object_id": null, 52 | "assigned_object": null, 53 | "nat_inside": null, 54 | "nat_outside": [], 55 | "dns_name": "dnsname-2001-0db8-3.example.com", 56 | "description": "description-2001-0db8-3.example.com", 57 | "tags": [], 58 | "custom_fields": {}, 59 | "created": "2022-09-21T07:41:47.574008Z", 60 | "last_updated": "2022-09-21T07:41:47.574033Z" 61 | } 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /tests/fixtures/ip_addresses_v6_non_nibble_boundary.json: -------------------------------------------------------------------------------- 1 | { 2 | "count": 2, 3 | "next": null, 4 | "previous": null, 5 | "results": [ 6 | { 7 | "id": 1774, 8 | "url": "https://ipam.example.com/api/ipam/ip-addresses/1774/", 9 | "display": "2001:0db8::2/100", 10 | "family": { 11 | "value": 6, 12 | "label": "IPv6" 13 | }, 14 | "address": "2001:0db8::2/100", 15 | "vrf": null, 16 | "tenant": null, 17 | "status": { 18 | "value": "active", 19 | "label": "Active" 20 | }, 21 | "role": null, 22 | "assigned_object_type": null, 23 | "assigned_object_id": null, 24 | "assigned_object": null, 25 | "nat_inside": null, 26 | "nat_outside": [], 27 | "dns_name": "dnsname-2001-0db8-2.example.com", 28 | "description": "description-2001-0db8-2.example.com", 29 | "tags": [], 30 | "custom_fields": {}, 31 | "created": "2022-09-21T07:41:47.545639Z", 32 | "last_updated": "2022-09-21T07:41:47.545662Z" 33 | }, 34 | { 35 | "id": 1775, 36 | "url": "https://ipam.example.com/api/ipam/ip-addresses/1775/", 37 | "display": "2001:0db8::3/100", 38 | "family": { 39 | "value": 6, 40 | "label": "IPv6" 41 | }, 42 | "address": "2001:0db8::3/100", 43 | "vrf": null, 44 | "tenant": null, 45 | "status": { 46 | "value": "active", 47 | "label": "Active" 48 | }, 49 | "role": null, 50 | "assigned_object_type": null, 51 | "assigned_object_id": null, 52 | "assigned_object": null, 53 | "nat_inside": null, 54 | "nat_outside": [], 55 | "dns_name": "dnsname-2001-0db8-3.example.com", 56 | "description": "description-2001-0db8-3.example.com", 57 | "tags": [], 58 | "custom_fields": {}, 59 | "created": "2022-09-21T07:41:47.574008Z", 60 | "last_updated": "2022-09-21T07:41:47.574033Z" 61 | } 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /tests/fixtures/vrf.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 1, 3 | "url": "https://ipam.example.com/api/ipam/vrfs/1/", 4 | "display": "mgmt", 5 | "name": "mgmt", 6 | "rd": null, 7 | "tenant": null, 8 | "enforce_unique": true, 9 | "description": "management segment", 10 | "import_targets": [], 11 | "export_targets": [], 12 | "tags": [], 13 | "custom_fields": {}, 14 | "created": "2022-09-10T00:00:00Z", 15 | "last_updated": "2022-09-10T13:12:44.787038Z", 16 | "ipaddress_count": 144, 17 | "prefix_count": 26 18 | } 19 | -------------------------------------------------------------------------------- /tests/fixtures/vrfs.json: -------------------------------------------------------------------------------- 1 | { 2 | "count": 1, 3 | "next": null, 4 | "previous": null, 5 | "results": [ 6 | { 7 | "id": 1, 8 | "url": "https://ipam.example.com/api/ipam/vrfs/1/", 9 | "display": "mgmt", 10 | "name": "mgmt", 11 | "rd": null, 12 | "tenant": null, 13 | "enforce_unique": true, 14 | "description": "management segment", 15 | "import_targets": [], 16 | "export_targets": [], 17 | "tags": [], 18 | "custom_fields": {}, 19 | "created": "2022-09-10T00:00:00Z", 20 | "last_updated": "2022-09-10T13:12:44.787038Z", 21 | "ipaddress_count": 144, 22 | "prefix_count": 26 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /tests/test_octodns_netbox.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests_mock 3 | from octodns.record import Record 4 | from octodns.zone import Zone 5 | from pydantic import ValidationError 6 | 7 | from octodns_netbox import NetboxSource 8 | 9 | from .util import SimpleProvider, load_fixture 10 | 11 | 12 | @pytest.fixture(autouse=True) 13 | def mock_requests(): 14 | with requests_mock.Mocker() as mock: 15 | mock.get( 16 | "http://netbox.example.com/api/ipam/vrfs/?limit=0", 17 | complete_qs=True, 18 | json=load_fixture("vrfs.json"), 19 | ) 20 | mock.get( 21 | "http://netbox.example.com/api/ipam/vrfs/?name=mgmt&limit=0", 22 | complete_qs=True, 23 | json=load_fixture("vrfs.json"), 24 | ) 25 | mock.get( 26 | "http://netbox.example.com/api/ipam/vrfs/?name=TEST&limit=0", 27 | complete_qs=True, 28 | json={"count": 0, "next": None, "previous": None, "results": []}, 29 | ) 30 | mock.get( 31 | "http://netbox.example.com/api/ipam/vrfs/1/", 32 | json=load_fixture("vrf.json"), 33 | ) 34 | mock.get( 35 | "http://netbox.example.com/api/ipam/ip-addresses/?parent=192.0.2.0%2F27&family=4&description__empty=false&limit=0", 36 | complete_qs=True, 37 | json=load_fixture("ip_addresses_v4_non_octet_boundary.json"), 38 | ) 39 | mock.get( 40 | "http://netbox.example.com/api/ipam/ip-addresses/?parent=192.0.2.0%2F27&family=4&dns_name__empty=false&limit=0", 41 | complete_qs=True, 42 | json=load_fixture("ip_addresses_v4_non_octet_boundary.json"), 43 | ) 44 | mock.get( 45 | "http://netbox.example.com/api/ipam/ip-addresses/?parent=192.0.2.0%2F27&family=4&vrf_id=1&description__empty=false&limit=0", 46 | complete_qs=True, 47 | json=load_fixture("ip_addresses_v4_non_octet_boundary_vrf_mgmt.json"), 48 | ) 49 | mock.get( 50 | "http://netbox.example.com/api/ipam/ip-addresses/?parent=192.0.2.0%2F24&family=4&description__empty=false&limit=0", 51 | complete_qs=True, 52 | json=load_fixture("ip_addresses_v4_octet_boundary.json"), 53 | ) 54 | mock.get( 55 | "http://netbox.example.com/api/ipam/ip-addresses/?parent=192.0.2.0%2F24&family=4&dns_name__empty=false&limit=0", 56 | complete_qs=True, 57 | json=load_fixture("ip_addresses_v4_octet_boundary.json"), 58 | ) 59 | mock.get( 60 | "http://netbox.example.com/api/ipam/ip-addresses/?parent=192.0.3.0%2F24&family=4&vrf_id=null&description__empty=false&limit=0", 61 | complete_qs=True, 62 | json=load_fixture("ip_addresses_v4_octet_boundary_vrf_global.json"), 63 | ) 64 | mock.get( 65 | "http://netbox.example.com/api/ipam/ip-addresses/?parent=2001%3Adb8%3A%3A%2F100&family=6&description__empty=false&limit=0", 66 | complete_qs=True, 67 | json=load_fixture("ip_addresses_v6_non_nibble_boundary.json"), 68 | ) 69 | mock.get( 70 | "http://netbox.example.com/api/ipam/ip-addresses/?parent=2001%3Adb8%3A%3A%2F100&family=6&dns_name__empty=false&limit=0", 71 | complete_qs=True, 72 | json=load_fixture("ip_addresses_v6_non_nibble_boundary.json"), 73 | ) 74 | mock.get( 75 | "http://netbox.example.com/api/ipam/ip-addresses/?parent=2001%3Adb8%3A%3A%2F64&family=6&description__empty=false&limit=0", 76 | complete_qs=True, 77 | json=load_fixture("ip_addresses_v6_nibble_boundary.json"), 78 | ) 79 | mock.get( 80 | "http://netbox.example.com/api/ipam/ip-addresses/?parent=2001%3Adb8%3A%3A%2F64&family=6&dns_name__empty=false&limit=0", 81 | complete_qs=True, 82 | json=load_fixture("ip_addresses_v6_nibble_boundary.json"), 83 | ) 84 | mock.get( 85 | "http://netbox.example.com/api/ipam/ip-addresses/?description__ic=example.com&limit=0", 86 | complete_qs=True, 87 | json=load_fixture("ip_addresses_example_com.json"), 88 | ) 89 | mock.get( 90 | "http://netbox.example.com/api/ipam/ip-addresses/?description__ic=subdomain1.example.com&limit=0", 91 | complete_qs=True, 92 | json=load_fixture("ip_addresses_subdomain1_example_com.json"), 93 | ) 94 | mock.get( 95 | "http://netbox.example.com/api/ipam/ip-addresses/?dns_name__ic=example.com&limit=0", 96 | complete_qs=True, 97 | json=load_fixture("ip_addresses_example_com.json"), 98 | ) 99 | 100 | yield mock 101 | 102 | 103 | class TestNetboxSourceFailSenarios: 104 | def test_init_failed_due_to_missing_url(self): 105 | with pytest.raises(ValidationError) as excinfo: 106 | NetboxSource("test") 107 | assert excinfo.value.errors()[0]["loc"] == ("url",) 108 | assert excinfo.value.errors()[0]["type"] == "missing" 109 | assert excinfo.value.errors()[1]["loc"] == ("token",) 110 | assert excinfo.value.errors()[1]["type"] == "missing" 111 | 112 | def test_init_failed_due_to_missing_token(self): 113 | with pytest.raises(ValidationError) as excinfo: 114 | NetboxSource("test", url="http://netbox.example.com/") 115 | assert excinfo.value.errors()[0]["loc"] == ("token",) 116 | assert excinfo.value.errors()[0]["type"] == "missing" 117 | 118 | def test_init_maintain_backword_compatibility_for_url(self): 119 | source = NetboxSource( 120 | "test", 121 | url="http://netbox.example.com/api/", 122 | token="testtoken", 123 | ) 124 | assert source.url == "http://netbox.example.com" 125 | 126 | def test_init_failed_due_to_invalid_field_name_type(self): 127 | with pytest.raises(ValidationError) as excinfo: 128 | NetboxSource( 129 | "test", 130 | url="http://netbox.example.com/", 131 | token="testtoken", 132 | field_name=["dns_name", "description"], 133 | ) 134 | 135 | assert excinfo.value.errors()[0]["loc"] == ("field_name",) 136 | assert excinfo.value.errors()[0]["type"] == "string_type" 137 | 138 | def test_init_failed_due_to_invalid_ttl_type(self): 139 | with pytest.raises(ValidationError) as excinfo: 140 | NetboxSource( 141 | "test", url="http://netbox.example.com/", token="testtoken", ttl=[10] 142 | ) 143 | assert excinfo.value.errors()[0]["loc"] == ("ttl",) 144 | assert excinfo.value.errors()[0]["type"] == "int_type" 145 | 146 | def test_init_failed_due_to_invalid_ttl_value(self): 147 | with pytest.raises(ValidationError) as excinfo: 148 | NetboxSource( 149 | "test", url="http://netbox.example.com/", token="testtoken", ttl="ten" 150 | ) 151 | assert excinfo.value.errors()[0]["loc"] == ("ttl",) 152 | assert excinfo.value.errors()[0]["type"] == "int_parsing" 153 | 154 | def test_init_failed_due_to_invalid_populate_tags_type(self): 155 | with pytest.raises(ValidationError) as excinfo: 156 | NetboxSource( 157 | "test", 158 | url="http://netbox.example.com/", 159 | token="testtoken", 160 | populate_tags="tag", 161 | ) 162 | assert excinfo.value.errors()[0]["loc"] == ("populate_tags",) 163 | assert excinfo.value.errors()[0]["type"] == "list_type" 164 | 165 | def test_init_failed_due_to_invalid_populate_vrf_id_type(self): 166 | with pytest.raises(ValidationError) as excinfo: 167 | NetboxSource( 168 | "test", 169 | url="http://netbox.example.com/", 170 | token="testtoken", 171 | populate_vrf_id=[10], 172 | ) 173 | assert excinfo.value.errors()[0]["loc"] == ("populate_vrf_id", "int") 174 | assert excinfo.value.errors()[0]["type"] == "int_type" 175 | assert excinfo.value.errors()[1]["loc"] == ( 176 | "populate_vrf_id", 177 | "literal['null']", 178 | ) 179 | assert excinfo.value.errors()[1]["type"] == "literal_error" 180 | 181 | def test_init_failed_due_to_invalid_populate_vrf_id_value(self): 182 | with pytest.raises(ValidationError) as excinfo: 183 | NetboxSource( 184 | "test", 185 | url="http://netbox.example.com/", 186 | token="testtoken", 187 | populate_vrf_id="ten", 188 | ) 189 | assert excinfo.value.errors()[0]["loc"] == ("populate_vrf_id", "int") 190 | assert excinfo.value.errors()[0]["type"] == "int_parsing" 191 | assert excinfo.value.errors()[1]["loc"] == ( 192 | "populate_vrf_id", 193 | "literal['null']", 194 | ) 195 | assert excinfo.value.errors()[1]["type"] == "literal_error" 196 | 197 | def test_init_failed_because_both_populate_vrf_id_populate_vrf_name_are_provided( 198 | self, 199 | ): 200 | with pytest.raises(ValidationError) as excinfo: 201 | NetboxSource( 202 | "test", 203 | url="http://netbox.example.com/", 204 | token="testtoken", 205 | populate_vrf_id=1, 206 | populate_vrf_name="TEST", 207 | ) 208 | assert "Do not set both populate_vrf_id and populate_vrf" in str(excinfo.value) 209 | 210 | def test_init_failed_due_to_invalid_populate_vrf_name_type(self): 211 | with pytest.raises(ValidationError) as excinfo: 212 | NetboxSource( 213 | "test", 214 | url="http://netbox.example.com/", 215 | token="testtoken", 216 | populate_vrf_name=["TEST"], 217 | ) 218 | assert excinfo.value.errors()[0]["loc"] == ("populate_vrf_name",) 219 | assert excinfo.value.errors()[0]["type"] == "string_type" 220 | 221 | def test_init_failed_because_invalid_populate_vrf_name_is_not_found_at_netbox(self): 222 | with pytest.raises(ValueError) as excinfo: 223 | NetboxSource( 224 | "test", 225 | url="http://netbox.example.com/", 226 | token="testtoken", 227 | populate_vrf_name="TEST", 228 | ) 229 | assert "Failed to retrieve VRF information by name" in str(excinfo.value) 230 | 231 | def test_init_failed_due_to_invalid_populate_subdomains_type(self): 232 | with pytest.raises(ValidationError) as excinfo: 233 | NetboxSource( 234 | "test", 235 | url="http://netbox.example.com/", 236 | token="testtoken", 237 | populate_subdomains="ok", 238 | ) 239 | assert excinfo.value.errors()[0]["loc"] == ("populate_subdomains",) 240 | assert excinfo.value.errors()[0]["type"] == "bool_parsing" 241 | 242 | 243 | class TestNetboxSourcePopulateIPv4PTRNonOctecBoundary: 244 | def test_populate_PTR_v4_non_octet_boundary(self): 245 | zone = Zone("0/27.2.0.192.in-addr.arpa.", []) 246 | source = NetboxSource( 247 | "test", url="http://netbox.example.com/", token="testtoken" 248 | ) 249 | source.populate(zone) 250 | 251 | assert len(zone.records) == 3 252 | 253 | expected = Zone("0/27.2.0.192.in-addr.arpa.", []) 254 | for name, data in ( 255 | ( 256 | "1", 257 | { 258 | "type": "PTR", 259 | "ttl": 60, 260 | "values": ["description-192-0-2-1.example.com."], 261 | }, 262 | ), 263 | ( 264 | "2", 265 | { 266 | "type": "PTR", 267 | "ttl": 60, 268 | "values": ["description-192-0-2-2.example.com."], 269 | }, 270 | ), 271 | ( 272 | "3", 273 | { 274 | "type": "PTR", 275 | "ttl": 60, 276 | "values": ["description-192-0-2-3.example.com."], 277 | }, 278 | ), 279 | ): 280 | record = Record.new(expected, name, data) 281 | expected.add_record(record) 282 | 283 | changes = expected.changes(zone, SimpleProvider()) 284 | assert changes == [] 285 | 286 | def test_populate_PTR_v4_non_octet_boundary_field_name_is_dns_name(self): 287 | zone = Zone("0/27.2.0.192.in-addr.arpa.", []) 288 | source = NetboxSource( 289 | "test", 290 | url="http://netbox.example.com/", 291 | token="testtoken", 292 | field_name="dns_name", 293 | ) 294 | source.populate(zone) 295 | 296 | assert len(zone.records) == 3 297 | 298 | expected = Zone("0/27.2.0.192.in-addr.arpa.", []) 299 | for name, data in ( 300 | ( 301 | "1", 302 | { 303 | "type": "PTR", 304 | "ttl": 60, 305 | "values": ["dnsname-192-0-2-1.example.com."], 306 | }, 307 | ), 308 | ( 309 | "2", 310 | { 311 | "type": "PTR", 312 | "ttl": 60, 313 | "values": ["dnsname-192-0-2-2.example.com."], 314 | }, 315 | ), 316 | ( 317 | "3", 318 | { 319 | "type": "PTR", 320 | "ttl": 60, 321 | "values": ["dnsname-192-0-2-3.example.com."], 322 | }, 323 | ), 324 | ): 325 | record = Record.new(expected, name, data) 326 | expected.add_record(record) 327 | 328 | changes = expected.changes(zone, SimpleProvider()) 329 | assert changes == [] 330 | 331 | def test_populate_PTR_v4_non_octet_boundary_custom_ttl(self): 332 | zone = Zone("0/27.2.0.192.in-addr.arpa.", []) 333 | source = NetboxSource( 334 | "test", url="http://netbox.example.com/", token="testtoken", ttl=120 335 | ) 336 | source.populate(zone) 337 | 338 | assert len(zone.records) == 3 339 | 340 | expected = Zone("0/27.2.0.192.in-addr.arpa.", []) 341 | for name, data in ( 342 | ( 343 | "1", 344 | { 345 | "type": "PTR", 346 | "ttl": 120, 347 | "values": ["description-192-0-2-1.example.com."], 348 | }, 349 | ), 350 | ( 351 | "2", 352 | { 353 | "type": "PTR", 354 | "ttl": 120, 355 | "values": ["description-192-0-2-2.example.com."], 356 | }, 357 | ), 358 | ( 359 | "3", 360 | { 361 | "type": "PTR", 362 | "ttl": 120, 363 | "values": ["description-192-0-2-3.example.com."], 364 | }, 365 | ), 366 | ): 367 | record = Record.new(expected, name, data) 368 | expected.add_record(record) 369 | 370 | changes = expected.changes(zone, SimpleProvider()) 371 | assert changes == [] 372 | 373 | def test_populate_PTR_v4_non_octet_boundary_select_vrf_by_id(self): 374 | zone = Zone("0/27.2.0.192.in-addr.arpa.", []) 375 | source = NetboxSource( 376 | "test", 377 | url="http://netbox.example.com/", 378 | token="testtoken", 379 | populate_vrf_id=1, 380 | ) 381 | source.populate(zone) 382 | 383 | assert len(zone.records) == 3 384 | 385 | expected = Zone("0/27.2.0.192.in-addr.arpa.", []) 386 | for name, data in ( 387 | ( 388 | "1", 389 | { 390 | "type": "PTR", 391 | "ttl": 60, 392 | "values": ["vrf-mgmt-description-192-0-2-1.example.com."], 393 | }, 394 | ), 395 | ( 396 | "2", 397 | { 398 | "type": "PTR", 399 | "ttl": 60, 400 | "values": ["vrf-mgmt-description-192-0-2-2.example.com."], 401 | }, 402 | ), 403 | ( 404 | "3", 405 | { 406 | "type": "PTR", 407 | "ttl": 60, 408 | "values": ["vrf-mgmt-description-192-0-2-3.example.com."], 409 | }, 410 | ), 411 | ): 412 | record = Record.new(expected, name, data) 413 | expected.add_record(record) 414 | 415 | changes = expected.changes(zone, SimpleProvider()) 416 | assert changes == [] 417 | 418 | def test_populate_PTR_v4_non_octet_boundary_select_vrf_by_name(self): 419 | zone = Zone("0/27.2.0.192.in-addr.arpa.", []) 420 | source = NetboxSource( 421 | "test", 422 | url="http://netbox.example.com/", 423 | token="testtoken", 424 | populate_vrf_name="mgmt", 425 | ) 426 | source.populate(zone) 427 | 428 | assert len(zone.records) == 3 429 | 430 | expected = Zone("0/27.2.0.192.in-addr.arpa.", []) 431 | for name, data in ( 432 | ( 433 | "1", 434 | { 435 | "type": "PTR", 436 | "ttl": 60, 437 | "values": ["vrf-mgmt-description-192-0-2-1.example.com."], 438 | }, 439 | ), 440 | ( 441 | "2", 442 | { 443 | "type": "PTR", 444 | "ttl": 60, 445 | "values": ["vrf-mgmt-description-192-0-2-2.example.com."], 446 | }, 447 | ), 448 | ( 449 | "3", 450 | { 451 | "type": "PTR", 452 | "ttl": 60, 453 | "values": ["vrf-mgmt-description-192-0-2-3.example.com."], 454 | }, 455 | ), 456 | ): 457 | record = Record.new(expected, name, data) 458 | expected.add_record(record) 459 | 460 | changes = expected.changes(zone, SimpleProvider()) 461 | assert changes == [] 462 | 463 | 464 | class TestNetboxSourcePopulateIPv4PTROctecBoundary: 465 | def test_populate_PTR_v4_octet_boundary(self): 466 | zone = Zone("2.0.192.in-addr.arpa.", []) 467 | source = NetboxSource( 468 | "test", 469 | url="http://netbox.example.com/", 470 | token="testtoken", 471 | ) 472 | source.populate(zone) 473 | 474 | assert len(zone.records) == 2 475 | 476 | expected = Zone("2.0.192.in-addr.arpa.", []) 477 | for name, data in ( 478 | ( 479 | "1", 480 | { 481 | "type": "PTR", 482 | "ttl": 60, 483 | "values": ["description-192-0-2-1.example.com."], 484 | }, 485 | ), 486 | ( 487 | "2", 488 | { 489 | "type": "PTR", 490 | "ttl": 60, 491 | "values": ["description-192-0-2-2.example.com."], 492 | }, 493 | ), 494 | ): 495 | record = Record.new(expected, name, data) 496 | expected.add_record(record) 497 | 498 | changes = expected.changes(zone, SimpleProvider()) 499 | assert changes == [] 500 | 501 | def test_populate_PTR_v4_octet_boundary_multivalue_ptr_enabled(self): 502 | zone = Zone("2.0.192.in-addr.arpa.", []) 503 | source = NetboxSource( 504 | "test", 505 | url="http://netbox.example.com/", 506 | token="testtoken", 507 | multivalue_ptr=True, 508 | ) 509 | source.populate(zone) 510 | 511 | assert len(zone.records) == 2 512 | 513 | expected = Zone("2.0.192.in-addr.arpa.", []) 514 | for name, data in ( 515 | ( 516 | "1", 517 | { 518 | "type": "PTR", 519 | "ttl": 60, 520 | "values": ["description-192-0-2-1.example.com."], 521 | }, 522 | ), 523 | ( 524 | "2", 525 | { 526 | "type": "PTR", 527 | "ttl": 60, 528 | "values": [ 529 | "description-192-0-2-2.example.com.", 530 | "description-multiptr-192-0-2-2.example.com.", 531 | ], 532 | }, 533 | ), 534 | ): 535 | record = Record.new(expected, name, data) 536 | expected.add_record(record) 537 | 538 | changes = expected.changes(zone, SimpleProvider()) 539 | assert changes == [] 540 | 541 | def test_populate_PTR_v4_octet_boundary_field_name_is_dns_name(self): 542 | zone = Zone("2.0.192.in-addr.arpa.", []) 543 | source = NetboxSource( 544 | "test", 545 | url="http://netbox.example.com/", 546 | token="testtoken", 547 | field_name="dns_name", 548 | ) 549 | source.populate(zone) 550 | 551 | assert len(zone.records) == 2 552 | 553 | expected = Zone("2.0.192.in-addr.arpa.", []) 554 | for name, data in ( 555 | ( 556 | "1", 557 | { 558 | "type": "PTR", 559 | "ttl": 60, 560 | "values": ["dnsname-192-0-2-1.example.com."], 561 | }, 562 | ), 563 | ( 564 | "2", 565 | { 566 | "type": "PTR", 567 | "ttl": 60, 568 | "values": ["dnsname-192-0-2-2.example.com."], 569 | }, 570 | ), 571 | ): 572 | record = Record.new(expected, name, data) 573 | expected.add_record(record) 574 | 575 | changes = expected.changes(zone, SimpleProvider()) 576 | assert changes == [] 577 | 578 | def test_populate_PTR_v4_octet_boundary_vrf_global_by_id(self): 579 | zone = Zone("3.0.192.in-addr.arpa.", []) 580 | source = NetboxSource( 581 | "test", 582 | url="http://netbox.example.com/", 583 | token="testtoken", 584 | populate_vrf_id=0, 585 | ) 586 | source.populate(zone) 587 | 588 | assert len(zone.records) == 1 589 | 590 | expected = Zone("3.0.192.in-addr.arpa.", []) 591 | for name, data in ( 592 | ( 593 | "1", 594 | { 595 | "type": "PTR", 596 | "ttl": 60, 597 | "values": ["description-192-0-3-1.example.com."], 598 | }, 599 | ), 600 | ): 601 | record = Record.new(expected, name, data) 602 | expected.add_record(record) 603 | 604 | changes = expected.changes(zone, SimpleProvider()) 605 | assert changes == [] 606 | 607 | def test_populate_PTR_v4_octet_boundary_vrf_global_by_name(self): 608 | zone = Zone("3.0.192.in-addr.arpa.", []) 609 | source = NetboxSource( 610 | "test", 611 | url="http://netbox.example.com/", 612 | token="testtoken", 613 | populate_vrf_name="Global", 614 | ) 615 | source.populate(zone) 616 | 617 | assert len(zone.records) == 1 618 | 619 | expected = Zone("3.0.192.in-addr.arpa.", []) 620 | for name, data in ( 621 | ( 622 | "1", 623 | { 624 | "type": "PTR", 625 | "ttl": 60, 626 | "values": ["description-192-0-3-1.example.com."], 627 | }, 628 | ), 629 | ): 630 | record = Record.new(expected, name, data) 631 | expected.add_record(record) 632 | 633 | changes = expected.changes(zone, SimpleProvider()) 634 | assert changes == [] 635 | 636 | 637 | class TestNetboxSourcePopulateIPv6PTRNonNibbleBoundary: 638 | def test_populate_PTR_v6_non_nibble_boundary(self): 639 | zone = Zone("0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", []) 640 | source = NetboxSource( 641 | "test", url="http://netbox.example.com/", token="testtoken" 642 | ) 643 | source.populate(zone) 644 | 645 | assert len(zone.records) == 2 646 | 647 | expected = Zone( 648 | "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", [] 649 | ) 650 | for name, data in ( 651 | ( 652 | "2.0.0.0.0.0.0", 653 | { 654 | "type": "PTR", 655 | "ttl": 60, 656 | "values": ["description-2001-0db8-2.example.com."], 657 | }, 658 | ), 659 | ( 660 | "3.0.0.0.0.0.0", 661 | { 662 | "type": "PTR", 663 | "ttl": 60, 664 | "values": ["description-2001-0db8-3.example.com."], 665 | }, 666 | ), 667 | ): 668 | record = Record.new(expected, name, data) 669 | expected.add_record(record) 670 | 671 | changes = expected.changes(zone, SimpleProvider()) 672 | assert changes == [] 673 | 674 | def test_populate_PTR_v6_non_nibble_boundary_field_name_is_dns_name(self): 675 | zone = Zone("0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", []) 676 | source = NetboxSource( 677 | "test", 678 | url="http://netbox.example.com/", 679 | token="testtoken", 680 | field_name="dns_name", 681 | ) 682 | source.populate(zone) 683 | 684 | assert len(zone.records) == 2 685 | 686 | expected = Zone( 687 | "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", [] 688 | ) 689 | for name, data in ( 690 | ( 691 | "2.0.0.0.0.0.0", 692 | { 693 | "type": "PTR", 694 | "ttl": 60, 695 | "values": ["dnsname-2001-0db8-2.example.com."], 696 | }, 697 | ), 698 | ( 699 | "3.0.0.0.0.0.0", 700 | { 701 | "type": "PTR", 702 | "ttl": 60, 703 | "values": ["dnsname-2001-0db8-3.example.com."], 704 | }, 705 | ), 706 | ): 707 | record = Record.new(expected, name, data) 708 | expected.add_record(record) 709 | 710 | changes = expected.changes(zone, SimpleProvider()) 711 | assert changes == [] 712 | 713 | 714 | class TestNetboxSourcePopulateIPv6PTRNibbleBoundary: 715 | def test_populate_PTR_v6_nibble_boundary(self): 716 | zone = Zone("0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", []) 717 | source = NetboxSource( 718 | "test", url="http://netbox.example.com/", token="testtoken" 719 | ) 720 | source.populate(zone) 721 | 722 | assert len(zone.records) == 2 723 | 724 | expected = Zone("0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", []) 725 | for name, data in ( 726 | ( 727 | "2.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0", 728 | { 729 | "type": "PTR", 730 | "ttl": 60, 731 | "values": ["description-2001-0db8-2.example.com."], 732 | }, 733 | ), 734 | ( 735 | "3.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0", 736 | { 737 | "type": "PTR", 738 | "ttl": 60, 739 | "values": ["description-2001-0db8-3.example.com."], 740 | }, 741 | ), 742 | ): 743 | record = Record.new(expected, name, data) 744 | expected.add_record(record) 745 | 746 | changes = expected.changes(zone, SimpleProvider()) 747 | assert changes == [] 748 | 749 | def test_populate_PTR_v6_name_nibble_boundary_field_is_dns_name(self): 750 | zone = Zone("0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", []) 751 | source = NetboxSource( 752 | "test", 753 | url="http://netbox.example.com/", 754 | token="testtoken", 755 | field_name="dns_name", 756 | ) 757 | source.populate(zone) 758 | 759 | assert len(zone.records) == 2 760 | 761 | expected = Zone("0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", []) 762 | for name, data in ( 763 | ( 764 | "2.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0", 765 | { 766 | "type": "PTR", 767 | "ttl": 60, 768 | "values": ["dnsname-2001-0db8-2.example.com."], 769 | }, 770 | ), 771 | ( 772 | "3.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0", 773 | { 774 | "type": "PTR", 775 | "ttl": 60, 776 | "values": ["dnsname-2001-0db8-3.example.com."], 777 | }, 778 | ), 779 | ): 780 | record = Record.new(expected, name, data) 781 | expected.add_record(record) 782 | 783 | changes = expected.changes(zone, SimpleProvider()) 784 | assert changes == [] 785 | 786 | 787 | class TestNetboxSourcePopulateNormal: 788 | def test_populate_A_and_AAAA(self): 789 | zone = Zone("example.com.", []) 790 | source = NetboxSource( 791 | "test", url="http://netbox.example.com/", token="testtoken" 792 | ) 793 | source.populate(zone) 794 | 795 | assert len(zone.records) == 12 796 | 797 | expected = Zone("example.com.", []) 798 | for name, data in ( 799 | ( 800 | "description-host1", 801 | { 802 | "type": "A", 803 | "ttl": 60, 804 | "values": ["192.0.4.1"], 805 | }, 806 | ), 807 | ( 808 | "description-host1", 809 | { 810 | "type": "AAAA", 811 | "ttl": 60, 812 | "values": ["2001:db8::1:1"], 813 | }, 814 | ), 815 | ( 816 | "description-host2.subdomain1", 817 | { 818 | "type": "A", 819 | "ttl": 60, 820 | "values": ["192.0.4.2"], 821 | }, 822 | ), 823 | ( 824 | "description-host2.subdomain1", 825 | { 826 | "type": "AAAA", 827 | "ttl": 60, 828 | "values": ["2001:db8::1:2"], 829 | }, 830 | ), 831 | ( 832 | "description-host2.subdomain2", 833 | { 834 | "type": "A", 835 | "ttl": 60, 836 | "values": ["192.0.4.2"], 837 | }, 838 | ), 839 | ( 840 | "description-host2.subdomain2", 841 | { 842 | "type": "AAAA", 843 | "ttl": 60, 844 | "values": ["2001:db8::1:2"], 845 | }, 846 | ), 847 | ( 848 | "description-host3.subdomain2", 849 | { 850 | "type": "A", 851 | "ttl": 60, 852 | "values": ["192.0.4.3"], 853 | }, 854 | ), 855 | ( 856 | "description-host3.subdomain2", 857 | { 858 | "type": "AAAA", 859 | "ttl": 60, 860 | "values": ["2001:db8::1:3"], 861 | }, 862 | ), 863 | ( 864 | "description-subdomain1", 865 | { 866 | "type": "AAAA", 867 | "ttl": 60, 868 | "values": ["2001:db8::1:4"], 869 | }, 870 | ), 871 | ( 872 | "description-roundrobin", 873 | { 874 | "type": "A", 875 | "ttl": 60, 876 | "values": ["192.0.4.5", "192.0.4.6"], 877 | }, 878 | ), 879 | ( 880 | "description-roundrobin", 881 | { 882 | "type": "AAAA", 883 | "ttl": 60, 884 | "values": ["2001:db8::1:5", "2001:db8::1:6"], 885 | }, 886 | ), 887 | ( 888 | "", 889 | { 890 | "type": "AAAA", 891 | "ttl": 60, 892 | "values": ["2001:db8::1:7"], 893 | }, 894 | ), 895 | ): 896 | record = Record.new(expected, name, data) 897 | expected.add_record(record) 898 | 899 | changes = expected.changes(zone, SimpleProvider()) 900 | assert changes == [] 901 | 902 | def test_populate_populate_subdomains_is_False(self, caplog): 903 | zone = Zone("subdomain1.example.com.", []) 904 | source = NetboxSource( 905 | "test", 906 | url="http://netbox.example.com/", 907 | token="testtoken", 908 | populate_subdomains=False, 909 | ) 910 | source.populate(zone) 911 | 912 | assert len(zone.records) == 3 913 | 914 | expected = Zone("example.com.", []) 915 | for name, data in ( 916 | ( 917 | "", 918 | { 919 | "type": "A", 920 | "ttl": 60, 921 | "values": ["192.0.4.4"], 922 | }, 923 | ), 924 | ( 925 | "description-host2", 926 | { 927 | "type": "A", 928 | "ttl": 60, 929 | "values": ["192.0.4.2"], 930 | }, 931 | ), 932 | ( 933 | "description-host2", 934 | { 935 | "type": "AAAA", 936 | "ttl": 60, 937 | "values": ["2001:db8::1:2"], 938 | }, 939 | ), 940 | ): 941 | record = Record.new(expected, name, data) 942 | expected.add_record(record) 943 | 944 | changes = expected.changes(zone, SimpleProvider()) 945 | assert changes == [] 946 | 947 | def test_populate_A_and_AAAA_field_is_dns_name(self): 948 | zone = Zone("example.com.", []) 949 | source = NetboxSource( 950 | "test", 951 | url="http://netbox.example.com/", 952 | token="testtoken", 953 | field_name="dns_name", 954 | ) 955 | source.populate(zone) 956 | 957 | assert len(zone.records) == 9 958 | 959 | expected = Zone("example.com.", []) 960 | for name, data in ( 961 | ( 962 | "dnsname-host1", 963 | { 964 | "type": "A", 965 | "ttl": 60, 966 | "values": ["192.0.4.1"], 967 | }, 968 | ), 969 | ( 970 | "dnsname-host1", 971 | { 972 | "type": "AAAA", 973 | "ttl": 60, 974 | "values": ["2001:db8::1:1"], 975 | }, 976 | ), 977 | ( 978 | "dnsname-host2.subdomain1", 979 | { 980 | "type": "A", 981 | "ttl": 60, 982 | "values": ["192.0.4.2"], 983 | }, 984 | ), 985 | ( 986 | "dnsname-host2.subdomain1", 987 | { 988 | "type": "AAAA", 989 | "ttl": 60, 990 | "values": ["2001:db8::1:2"], 991 | }, 992 | ), 993 | ( 994 | "subdomain1", 995 | { 996 | "type": "A", 997 | "ttl": 60, 998 | "values": ["192.0.4.4"], 999 | }, 1000 | ), 1001 | ( 1002 | "dnsname-subdomain1", 1003 | { 1004 | "type": "AAAA", 1005 | "ttl": 60, 1006 | "values": ["2001:db8::1:4"], 1007 | }, 1008 | ), 1009 | ( 1010 | "dnsname-roundrobin", 1011 | { 1012 | "type": "A", 1013 | "ttl": 60, 1014 | "values": ["192.0.4.5", "192.0.4.6"], 1015 | }, 1016 | ), 1017 | ( 1018 | "dnsname-roundrobin", 1019 | { 1020 | "type": "AAAA", 1021 | "ttl": 60, 1022 | "values": ["2001:db8::1:5", "2001:db8::1:6"], 1023 | }, 1024 | ), 1025 | ( 1026 | "", 1027 | { 1028 | "type": "AAAA", 1029 | "ttl": 60, 1030 | "values": "2001:db8::1:7", 1031 | }, 1032 | ), 1033 | ): 1034 | record = Record.new(expected, name, data) 1035 | expected.add_record(record) 1036 | 1037 | changes = expected.changes(zone, SimpleProvider()) 1038 | assert changes == [] 1039 | 1040 | def test_populate_A_and_AAAA_field_is_dns_name_populate_subdomains_is_False(self): 1041 | zone = Zone("example.com.", []) 1042 | source = NetboxSource( 1043 | "test", 1044 | url="http://netbox.example.com/", 1045 | token="testtoken", 1046 | field_name="dns_name", 1047 | populate_subdomains=False, 1048 | ) 1049 | source.populate(zone) 1050 | 1051 | assert len(zone.records) == 7 1052 | 1053 | expected = Zone("example.com.", []) 1054 | for name, data in ( 1055 | ( 1056 | "dnsname-host1", 1057 | { 1058 | "type": "A", 1059 | "ttl": 60, 1060 | "values": ["192.0.4.1"], 1061 | }, 1062 | ), 1063 | ( 1064 | "dnsname-host1", 1065 | { 1066 | "type": "AAAA", 1067 | "ttl": 60, 1068 | "values": ["2001:db8::1:1"], 1069 | }, 1070 | ), 1071 | ( 1072 | "subdomain1", 1073 | { 1074 | "type": "A", 1075 | "ttl": 60, 1076 | "values": ["192.0.4.4"], 1077 | }, 1078 | ), 1079 | ( 1080 | "dnsname-subdomain1", 1081 | { 1082 | "type": "AAAA", 1083 | "ttl": 60, 1084 | "values": ["2001:db8::1:4"], 1085 | }, 1086 | ), 1087 | ( 1088 | "dnsname-roundrobin", 1089 | { 1090 | "type": "A", 1091 | "ttl": 60, 1092 | "values": ["192.0.4.5", "192.0.4.6"], 1093 | }, 1094 | ), 1095 | ( 1096 | "dnsname-roundrobin", 1097 | { 1098 | "type": "AAAA", 1099 | "ttl": 60, 1100 | "values": ["2001:db8::1:5", "2001:db8::1:6"], 1101 | }, 1102 | ), 1103 | ( 1104 | "", 1105 | { 1106 | "type": "AAAA", 1107 | "ttl": 60, 1108 | "values": "2001:db8::1:7", 1109 | }, 1110 | ), 1111 | ): 1112 | record = Record.new(expected, name, data) 1113 | expected.add_record(record) 1114 | 1115 | changes = expected.changes(zone, SimpleProvider()) 1116 | assert changes == [] 1117 | 1118 | def test_populate_A_and_AAAA_field_is_dns_name_populate_and_defined_sub_zones( 1119 | self, caplog 1120 | ): 1121 | zone = Zone("example.com.", {"subdomain1"}) 1122 | source = NetboxSource( 1123 | "test", 1124 | url="http://netbox.example.com/", 1125 | token="testtoken", 1126 | field_name="dns_name", 1127 | ) 1128 | source.populate(zone) 1129 | 1130 | assert "Skipping subzone record" in caplog.text 1131 | assert len(zone.records) == 6 1132 | -------------------------------------------------------------------------------- /tests/test_reversename.py: -------------------------------------------------------------------------------- 1 | import ipaddress 2 | 3 | import pytest 4 | from octodns.zone import Zone 5 | 6 | from octodns_netbox.reversename import from_address, to_network 7 | 8 | 9 | class TestToNetworkIPv4: 10 | def test_to_network_ipv4_non_octet_boundary_rfc4183(self): 11 | zone_rfc4183_1 = Zone("0-26.2.100.10.in-addr.arpa.", []) 12 | assert to_network(zone_rfc4183_1) == ipaddress.IPv4Network("10.100.2.0/26") 13 | 14 | def test_to_network_ipv4_non_octet_boundary_rfc2317(self): 15 | zone_rfc2317_1 = Zone("0/26.2.100.10.in-addr.arpa.", []) 16 | assert to_network(zone_rfc2317_1) == ipaddress.IPv4Network("10.100.2.0/26") 17 | 18 | def test_to_network_ipv4_octet_boundary(self): 19 | zone_1 = Zone("10.in-addr.arpa.", []) 20 | assert to_network(zone_1) == ipaddress.IPv4Network("10.0.0.0/8") 21 | 22 | zone_2 = Zone("20.10.in-addr.arpa.", []) 23 | assert to_network(zone_2) == ipaddress.IPv4Network("10.20.0.0/16") 24 | 25 | zone_3 = Zone("30.20.10.in-addr.arpa.", []) 26 | assert to_network(zone_3) == ipaddress.IPv4Network("10.20.30.0/24") 27 | 28 | def test_to_network_fails_due_to_invalid_zone(self): 29 | zone_invalid_1 = Zone("300.10.in-addr.arpa.", []) 30 | with pytest.raises(ValueError) as excinfo: 31 | to_network(zone_invalid_1) 32 | assert "Failed to parse the leftmost IPv4 label" in str(excinfo.value) 33 | 34 | zone_invalid_2_1 = Zone("300-20.10.in-addr.arpa.", []) 35 | with pytest.raises(ValueError) as excinfo: 36 | to_network(zone_invalid_2_1) 37 | assert "Failed to parse the leftmost IPv4 label" in str(excinfo.value) 38 | 39 | zone_invalid_2_2 = Zone("300/20.10.in-addr.arpa.", []) 40 | with pytest.raises(ValueError) as excinfo: 41 | to_network(zone_invalid_2_2) 42 | assert "Failed to parse the leftmost IPv4 label" in str(excinfo.value) 43 | 44 | zone_invalid_3_1 = Zone("30-35.10.in-addr.arpa.", []) 45 | with pytest.raises(ValueError) as excinfo: 46 | to_network(zone_invalid_3_1) 47 | assert "Failed to parse the leftmost IPv4 label" in str(excinfo.value) 48 | 49 | zone_invalid_3_2 = Zone("30/35.10.in-addr.arpa.", []) 50 | with pytest.raises(ValueError) as excinfo: 51 | to_network(zone_invalid_3_2) 52 | assert "Failed to parse the leftmost IPv4 label" in str(excinfo.value) 53 | 54 | zone_invalid_4 = Zone("30/35.10.in-addr.arpa.example.com.", []) 55 | with pytest.raises(ValueError) as excinfo: 56 | to_network(zone_invalid_4) 57 | assert "Invalid reverse zone" in str(excinfo.value) 58 | 59 | 60 | class TestToNetworkIPv6: 61 | def test_to_network_ipv6(self): 62 | zone_56 = Zone("4.3.2.1.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", []) 63 | assert to_network(zone_56) == ipaddress.IPv6Network("2001:db8:12:3400::/56") 64 | 65 | zone_64 = Zone("0.0.0.0.c.d.b.a.8.b.d.0.1.0.0.2.ip6.arpa.", []) 66 | assert to_network(zone_64) == ipaddress.IPv6Network("2001:db8:abdc::/64") 67 | 68 | 69 | class TestFromAddressIPv4: 70 | def test_from_address_ipv4_non_octet_boundary(self): 71 | zone_rfc4183_1 = Zone("0-26.2.100.10.in-addr.arpa.", []) 72 | assert ( 73 | from_address(zone_rfc4183_1, ipaddress.IPv4Address("10.100.2.1")) 74 | == "1.0-26.2.100.10.in-addr.arpa." 75 | ) 76 | 77 | zone_rfc2317_1 = Zone("0/26.2.100.10.in-addr.arpa.", []) 78 | assert ( 79 | from_address(zone_rfc2317_1, ipaddress.IPv4Address("10.100.2.1")) 80 | == "1.0/26.2.100.10.in-addr.arpa." 81 | ) 82 | 83 | def test_from_address_ipv4_octet_boundary(self): 84 | zone_1 = Zone("10.in-addr.arpa.", []) 85 | assert ( 86 | from_address(zone_1, ipaddress.IPv4Address("10.100.2.1")) 87 | == "1.2.100.10.in-addr.arpa." 88 | ) 89 | 90 | zone_2 = Zone("20.10.in-addr.arpa.", []) 91 | assert ( 92 | from_address(zone_2, ipaddress.IPv4Address("10.20.2.1")) 93 | == "1.2.20.10.in-addr.arpa." 94 | ) 95 | 96 | zone_3 = Zone("30.20.10.in-addr.arpa.", []) 97 | assert ( 98 | from_address(zone_3, ipaddress.IPv4Address("10.20.30.40")) 99 | == "40.30.20.10.in-addr.arpa." 100 | ) 101 | 102 | def test_from_address_fails_due_to_invalid_zone(self): 103 | zone_invalid_1 = Zone("10.in-addr.arpa.example.com.", []) 104 | with pytest.raises(ValueError) as excinfo: 105 | from_address(zone_invalid_1, ipaddress.IPv4Address("10.100.2.1")) 106 | assert "Invalid reverse zone" in str(excinfo.value) 107 | 108 | 109 | class TestFromAddressIPv6: 110 | def test_from_address_ipv6(self): 111 | zone_64 = Zone("0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", []) 112 | assert ( 113 | from_address(zone_64, ipaddress.IPv6Address("2001:db8::1")) 114 | == "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa." 115 | ) 116 | 117 | def test_from_address_ipv6_fails_due_to_invalid_zone(self): 118 | zone_invalid_1 = Zone( 119 | "0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.example.com.", [] 120 | ) 121 | with pytest.raises(ValueError) as excinfo: 122 | from_address(zone_invalid_1, ipaddress.IPv6Address("2001:db8::1")) 123 | assert "Invalid reverse zone" in str(excinfo.value) 124 | -------------------------------------------------------------------------------- /tests/util.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | 5 | def load_fixture(filename): 6 | """Load a fixture.""" 7 | with open( 8 | os.path.join(os.path.dirname(__file__), "fixtures", filename), encoding="utf-8" 9 | ) as fp: 10 | data = json.load(fp) 11 | return data 12 | 13 | 14 | class SimpleProvider(object): 15 | SUPPORTS_GEO = False 16 | SUPPORTS_DYNAMIC = False 17 | SUPPORTS = set(("A", "AAAA", "PTR")) 18 | id = "test" 19 | 20 | def __init__(self, id="test"): 21 | pass 22 | 23 | def populate(self, zone, source=False, lenient=False): 24 | pass 25 | 26 | def supports(self, record): 27 | return True 28 | 29 | def __repr__(self): 30 | return self.__class__.__name__ 31 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = true 3 | envlist = 4 | py39, py310, py311, py312, py313 5 | lint, packaging 6 | 7 | [gh-actions] 8 | python = 9 | 3.13: py313, lint, packaging 10 | 3.12: py312 11 | 3.11: py311 12 | 3.10: py310 13 | 3.9: py39 14 | 15 | [testenv] 16 | setenv = 17 | PYTHONIOENCODING=utf-8 18 | PY_COLORS=1 19 | passenv = CI 20 | skip_install = true 21 | allowlist_externals = 22 | poetry 23 | commands_pre = 24 | poetry self update 25 | poetry install --with dev -v 26 | commands = 27 | poetry run pytest --cov=octodns_netbox --cov-report=xml --cov-report term-missing [] 28 | 29 | [testenv:packaging] 30 | skip_install = True 31 | deps = 32 | poetry 33 | twine 34 | commands = 35 | poetry build 36 | twine check dist/* 37 | 38 | [testenv:lint] 39 | skip_install = True 40 | passenv = TERM 41 | deps = pre-commit 42 | commands_pre = 43 | commands = 44 | pre-commit run [] --all-files --show-diff-on-failure --hook-stage=manual 45 | --------------------------------------------------------------------------------