├── .bandit.yml ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── ci.yml ├── .gitignore ├── .pydocstyle.ini ├── .yamllint.yml ├── FAQ.md ├── LICENSE ├── README.md ├── development ├── Dockerfile ├── configuration.py ├── dev.env └── docker-compose.yml ├── docs ├── examples │ └── example_ios_set_device_role.py ├── images │ ├── csv_import_view.png │ ├── menu.png │ ├── onboarding_tasks_view.png │ └── single_device_form.png └── release-notes │ └── version-2.0.md ├── netbox_onboarding ├── __init__.py ├── admin.py ├── api │ ├── __init__.py │ ├── serializers.py │ ├── urls.py │ └── views.py ├── choices.py ├── constants.py ├── exceptions.py ├── filters.py ├── forms.py ├── helpers.py ├── metrics.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_onboardingdevice.py │ ├── 0003_onboardingtask_change_logging_model.py │ ├── 0004_create_onboardingdevice.py │ └── __init__.py ├── models.py ├── navigation.py ├── netbox_keeper.py ├── netdev_keeper.py ├── onboard.py ├── onboarding │ ├── __init__.py │ └── onboarding.py ├── onboarding_extensions │ ├── __init__.py │ └── ios.py ├── release.py ├── tables.py ├── template_content.py ├── templates │ └── netbox_onboarding │ │ ├── device_onboarding_table.html │ │ ├── onboarding_task_edit.html │ │ ├── onboarding_tasks_list.html │ │ ├── onboardingtask.html │ │ ├── onboardingtask_ge210.html │ │ └── onboardingtask_lt210.html ├── tests │ ├── __init__.py │ ├── test_api.py │ ├── test_models.py │ ├── test_netbox_keeper.py │ ├── test_netdev_keeper.py │ ├── test_views_28.py │ └── test_views_29.py ├── urls.py ├── utils │ └── credentials.py ├── views.py └── worker.py ├── poetry.lock ├── pyproject.toml └── tasks.py /.bandit.yml: -------------------------------------------------------------------------------- 1 | --- 2 | skips: [] -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Default owners for all files in this repository 2 | * @dgarros @mzbroch -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Bug Report 3 | about: Report a reproducible bug in the current release of ntc-netbox-plugin-onboarding 4 | --- 5 | 6 | ### Environment 7 | * Python version: 8 | * NetBox version: 9 | * ntc-netbox-plugin-onboarding version: 10 | 11 | 15 | ### Steps to Reproduce 16 | 1. 17 | 2. 18 | 3. 19 | 20 | 21 | ### Expected Behavior 22 | 23 | 24 | 25 | ### Observed Behavior -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: ✨ Feature Request 3 | about: Propose a new feature or enhancement 4 | 5 | --- 6 | 7 | ### Environment 8 | * Python version: 9 | * NetBox version: 10 | * ntc-netbox-plugin-onboarding version: 11 | 12 | 15 | ### Proposed Functionality 16 | 17 | 22 | ### Use Case 23 | 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "CI" 3 | concurrency: # Cancel any existing runs of this workflow for this same PR 4 | group: "${{ github.workflow }}-${{ github.ref }}" 5 | cancel-in-progress: true 6 | on: # yamllint disable 7 | push: 8 | branches: 9 | - "master" 10 | - "develop" 11 | tags: 12 | - "v*" 13 | pull_request: ~ 14 | jobs: 15 | build: 16 | runs-on: "ubuntu-20.04" 17 | env: 18 | PYTHON_VER: "3.7" 19 | NETBOX_VER: "v2.9.11" 20 | steps: 21 | - name: "Check out repository code" 22 | uses: "actions/checkout@v2" 23 | - name: "Setup environment" 24 | uses: "networktocode/gh-action-setup-poetry-environment@v3" 25 | - name: "Build Container" 26 | run: "poetry run invoke build" 27 | black: 28 | runs-on: "ubuntu-20.04" 29 | env: 30 | PYTHON_VER: "3.7" 31 | NETBOX_VER: "v2.9.11" 32 | steps: 33 | - name: "Check out repository code" 34 | uses: "actions/checkout@v3" 35 | - name: "Setup environment" 36 | uses: "networktocode/gh-action-setup-poetry-environment@v3" 37 | - name: "Build Container" 38 | run: "poetry run invoke build" 39 | - name: "Linting: black" 40 | run: "poetry run invoke black" 41 | needs: 42 | - "build" 43 | bandit: 44 | runs-on: "ubuntu-20.04" 45 | env: 46 | PYTHON_VER: "3.7" 47 | NETBOX_VER: "v2.9.11" 48 | steps: 49 | - name: "Check out repository code" 50 | uses: "actions/checkout@v3" 51 | - name: "Setup environment" 52 | uses: "networktocode/gh-action-setup-poetry-environment@v3" 53 | - name: "Build Container" 54 | run: "poetry run invoke build" 55 | - name: "Linting: bandit" 56 | run: "poetry run invoke bandit" 57 | needs: 58 | - "build" 59 | pydocstyle: 60 | runs-on: "ubuntu-20.04" 61 | env: 62 | PYTHON_VER: "3.7" 63 | NETBOX_VER: "v2.9.11" 64 | steps: 65 | - name: "Check out repository code" 66 | uses: "actions/checkout@v3" 67 | - name: "Setup environment" 68 | uses: "networktocode/gh-action-setup-poetry-environment@v3" 69 | - name: "Build Container" 70 | run: "poetry run invoke build" 71 | - name: "Linting: pydocstyle" 72 | run: "poetry run invoke pydocstyle" 73 | needs: 74 | - "build" 75 | pylint: 76 | runs-on: "ubuntu-20.04" 77 | env: 78 | PYTHON_VER: "3.7" 79 | NETBOX_VER: "v2.9.11" 80 | steps: 81 | - name: "Check out repository code" 82 | uses: "actions/checkout@v3" 83 | - name: "Setup environment" 84 | uses: "networktocode/gh-action-setup-poetry-environment@v3" 85 | - name: "Build Container" 86 | run: "poetry run invoke build" 87 | - name: "Linting: Pylint" 88 | run: "poetry run invoke pylint" 89 | needs: 90 | - "black" 91 | - "bandit" 92 | - "pydocstyle" 93 | unittest: 94 | strategy: 95 | fail-fast: true 96 | matrix: 97 | python-version: ["3.6", "3.7", "3.8"] 98 | netbox-version: ["v2.8.9", "v2.9.11", "v2.10.10", "v2.11.10"] 99 | runs-on: "ubuntu-20.04" 100 | env: 101 | PYTHON_VER: "${{ matrix.python-version }}" 102 | NETBOX_VER: "${{ matrix.netbox-version }}" 103 | steps: 104 | - name: "Check out repository code" 105 | uses: "actions/checkout@v3" 106 | - name: "Setup environment" 107 | uses: "networktocode/gh-action-setup-poetry-environment@v3" 108 | - name: "Build Container" 109 | run: "poetry run invoke build" 110 | - name: "Run Tests" 111 | run: "poetry run invoke unittest" 112 | needs: 113 | - "pylint" 114 | publish_gh: 115 | name: "Publish to GitHub" 116 | runs-on: "ubuntu-20.04" 117 | if: "startsWith(github.ref, 'refs/tags/v')" 118 | steps: 119 | - name: "Check out repository code" 120 | uses: "actions/checkout@v3" 121 | - name: "Set up Python" 122 | uses: "actions/setup-python@v4" 123 | with: 124 | python-version: "3.9" 125 | - name: "Install Python Packages" 126 | run: "pip install poetry" 127 | - name: "Set env" 128 | run: "echo RELEASE_VERSION=${GITHUB_REF:10} >> $GITHUB_ENV" 129 | - name: "Run Poetry Version" 130 | run: "poetry version $RELEASE_VERSION" 131 | - name: "Upload binaries to release" 132 | uses: "svenstaro/upload-release-action@v2" 133 | with: 134 | repo_token: "${{ secrets.NTC_GITHUB_TOKEN }}" 135 | file: "dist/*" 136 | tag: "${{ github.ref }}" 137 | overwrite: true 138 | file_glob: true 139 | needs: 140 | - "unittest" 141 | publish_pypi: 142 | name: "Push Package to PyPI" 143 | runs-on: "ubuntu-20.04" 144 | if: "startsWith(github.ref, 'refs/tags/v')" 145 | steps: 146 | - name: "Check out repository code" 147 | uses: "actions/checkout@v3" 148 | - name: "Set up Python" 149 | uses: "actions/setup-python@v4" 150 | with: 151 | python-version: "3.9" 152 | - name: "Install Python Packages" 153 | run: "pip install poetry" 154 | - name: "Set env" 155 | run: "echo RELEASE_VERSION=${GITHUB_REF:10} >> $GITHUB_ENV" 156 | - name: "Run Poetry Version" 157 | run: "poetry version $RELEASE_VERSION" 158 | - name: "Push to PyPI" 159 | uses: "pypa/gh-action-pypi-publish@release/v1" 160 | with: 161 | user: "__token__" 162 | password: "${{ secrets.PYPI_API_TOKEN }}" 163 | needs: 164 | - "unittest" 165 | slack-notify: 166 | needs: 167 | - "publish_gh" 168 | - "publish_pypi" 169 | name: "Send notification to the Slack" 170 | runs-on: "ubuntu-20.04" 171 | env: 172 | SLACK_WEBHOOK_URL: "${{ secrets.SLACK_WEBHOOK_URL }}" 173 | SLACK_MESSAGE: >- 174 | *NOTIFICATION: NEW-RELEASE-PUBLISHED*\n 175 | Repository: <${{ github.server_url }}/${{ github.repository }}|${{ github.repository }}>\n 176 | Release: <${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ github.ref_name }}|${{ github.ref_name }}>\n 177 | Published by: <${{ github.server_url }}/${{ github.actor }}|${{ github.actor }}> 178 | steps: 179 | - name: "Send a notification to Slack" 180 | # ENVs cannot be used directly in job.if. This is a workaround to check 181 | # if SLACK_WEBHOOK_URL is present. 182 | if: "${{ env.SLACK_WEBHOOK_URL != '' }}" 183 | uses: "slackapi/slack-github-action@v1.19.0" 184 | with: 185 | payload: | 186 | { 187 | "text": "${{ env.SLACK_MESSAGE }}", 188 | "blocks": [ 189 | { 190 | "type": "section", 191 | "text": { 192 | "type": "mrkdwn", 193 | "text": "${{ env.SLACK_MESSAGE }}" 194 | } 195 | } 196 | ] 197 | } 198 | env: 199 | SLACK_WEBHOOK_URL: "${{ secrets.SLACK_WEBHOOK_URL }}" 200 | SLACK_WEBHOOK_TYPE: "INCOMING_WEBHOOK" 201 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ntc_netbox_plugin_onboarding.egg-info 2 | __pycache__ 3 | *.swp 4 | dist 5 | .vscode/ 6 | -------------------------------------------------------------------------------- /.pydocstyle.ini: -------------------------------------------------------------------------------- 1 | [pydocstyle] 2 | convention = google 3 | inherit = false 4 | -------------------------------------------------------------------------------- /.yamllint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: default 3 | 4 | rules: 5 | truthy: disable 6 | brackets: { min-spaces-inside: 0, max-spaces-inside: 1 } 7 | braces: { min-spaces-inside: 0, max-spaces-inside: 1 } 8 | line-length: { allow-non-breakable-inline-mappings: true, allow-non-breakable-words: true, max: 180 } 9 | comments-indentation: disable 10 | -------------------------------------------------------------------------------- /FAQ.md: -------------------------------------------------------------------------------- 1 | # Frequently Asked Questions 2 | 3 | ## Is it possible to disable the automatic creation of Device Type, Device Role or Platform ? 4 | 5 | > **Yes**, Using the plugin settings, it's possible to control individually the creation of `device_role`, `device_type`, `manufacturer` & `platform` 6 | 7 | ``` 8 | # configuration.py 9 | # If need you can override the default settings 10 | # PLUGINS_CONFIG = { 11 | # "netbox_onboarding": { 12 | # "create_platform_if_missing": True, 13 | # "create_manufacturer_if_missing": True, 14 | # "create_device_type_if_missing": True, 15 | # "create_device_role_if_missing": True, 16 | # "default_device_role": "network", 17 | # } 18 | # } 19 | ``` 20 | 21 | ## How can I update the default credentials used to connect to a device ? 22 | 23 | > By default, the plugin is using the credentials defined in the main `configuration.py` for Napalm (`NAPALM_USERNAME`/`NAPALM_PASSWORD`). You can update the default credentials in `configuration.py` or you can provide specific one for each onboarding task. 24 | 25 | ## Does this plugin support the discovery and the creation of all interfaces and IP Addresses ? 26 | 27 | > **No**, The plugin will only discover and create the management interface and the management IP address. Importing all interfaces and IP addresses is a much larger problem that requires more preparation. This is out of scope of this project. 28 | 29 | ## Does this plugin support the discovery of device based on fqdn ? 30 | 31 | > **No**, Current the onbarding process is based on an IP address, please open an issue to discuss your use case if you would like to see support for FQDN based devices too. 32 | 33 | ## Does this plugin support the discovery of Stack or Virtual Chassis devices ? 34 | 35 | > **Partially**, Multi member devices (Stack, Virtual Chassis, FW Pair) can be imported but they will be created as a single device. 36 | 37 | ## Is this plugin able to automatically discover the type of my device ? 38 | 39 | > **Yes**, The plugin is leveraging [Netmiko](https://github.com/ktbyers/netmiko) & [Napalm](https://napalm.readthedocs.io/en/latest/) to attempt to automatically discover the OS and the model of each device. 40 | 41 | ## How many device can I import at the same time ? 42 | 43 | > **Many**, There are no strict limitations regarding the number of devices that can be imported. The speed at which devices will be imported will depend of the number of active RQ workers. 44 | 45 | ## Do I need to setup a dedicated RQ Worker node ? 46 | 47 | > **No**, The plugin is leveraging the existing RQ Worker infrastructure already in place in NetBox, the only requirement is to ensure the plugin itself is installed in the Worker node. 48 | 49 | ## Why don't I see a webhook generated when a new device is onboarded successfully ? 50 | 51 | > It's expected that any changes done asynchronously in NetBox currently (within a worker) will not generate a webhook. 52 | 53 | 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Network to Code 2 | Network to Code, LLC 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NetBox Onboarding plugin 2 | 3 | 4 | [![Build Status](https://travis-ci.com/networktocode/ntc-netbox-plugin-onboarding.svg?token=29s5AiDXdkDPwzSmDpxg&branch=master)](https://travis-ci.com/networktocode/ntc-netbox-plugin-onboarding) 5 | 6 | A plugin for [NetBox](https://github.com/netbox-community/netbox) to easily onboard new devices. 7 | 8 | `ntc-netbox-plugin-onboarding` is using [Netmiko](https://github.com/ktbyers/netmiko), [NAPALM](https://napalm.readthedocs.io/en/latest/) & [Django-RQ](https://github.com/rq/django-rq) to simplify the onboarding process of a new device into NetBox down to an IP Address and a site. 9 | The goal of this plugin is not to import everything about a device into NetBox but rather to help build quickly an inventory in NetBox that is often the first step into an automation journey. 10 | 11 | ## Installation 12 | 13 | If using the installation pattern from the NetBox Documentation, you will need to activate the 14 | virtual environment before installing so that you install the package into the virtual environment. 15 | 16 | ```shell 17 | cd /opt/netbox 18 | source venv/bin/activate 19 | ``` 20 | 21 | The plugin is available as a Python package in pypi and can be installed with pip. Once the 22 | installation is completed, then NetBox and the NetBox worker must be restarted. 23 | 24 | ```shell 25 | pip install ntc-netbox-plugin-onboarding 26 | systemctl restart netbox netbox-rq 27 | ``` 28 | 29 | ### Compatibility Matrix 30 | 31 | | | Netbox 2.8 | Netbox 2.9 | Netbox 2.10 | Netbox 2.11 | 32 | |-----------------------|------------|------------|-------------|-------------| 33 | | Onboarding Plugin 1.3 | X | | | | 34 | | Onboarding Plugin 2.0 | X | X | | | 35 | | Onboarding Plugin 2.1 | X | X | X | | 36 | | Onboarding Plugin 2.2 | X | X | X | X | 37 | 38 | To ensure NetBox Onboarding plugin is automatically re-installed during future upgrades, create a file named `local_requirements.txt` (if not already existing) in the NetBox root directory (alongside `requirements.txt`) and list the `ntc-netbox-plugin-onboarding` package: 39 | 40 | ```no-highlight 41 | # echo ntc-netbox-plugin-onboarding >> local_requirements.txt 42 | ``` 43 | 44 | Once installed, the plugin needs to be enabled in your `configuration.py` 45 | ```python 46 | # In your configuration.py 47 | PLUGINS = ["netbox_onboarding"] 48 | 49 | # PLUGINS_CONFIG = { 50 | # "netbox_onboarding": { 51 | # ADD YOUR SETTINGS HERE 52 | # } 53 | # } 54 | ``` 55 | 56 | Finally, make sure to run the migrations for this plugin 57 | 58 | ```bash 59 | python3 manage.py migrate 60 | ``` 61 | 62 | The plugin behavior can be controlled with the following list of settings 63 | 64 | - `create_platform_if_missing` boolean (default True), If True, a new platform object will be created if the platform discovered by netmiko do not already exist and is in the list of supported platforms (`cisco_ios`, `cisco_nxos`, `arista_eos`, `juniper_junos`, `cisco_xr`) 65 | - `create_device_type_if_missing` boolean (default True), If True, a new device type object will be created if the model discovered by Napalm do not match an existing device type. 66 | - `create_manufacturer_if_missing` boolean (default True), If True, a new manufacturer object will be created if the manufacturer discovered by Napalm is do not match an existing manufacturer, this option is only valid if `create_device_type_if_missing` is True as well. 67 | - `create_device_role_if_missing` boolean (default True), If True, a new device role object will be created if the device role provided was not provided as part of the onboarding and if the `default_device_role` do not already exist. 68 | - `create_management_interface_if_missing` boolean (default True), If True, add management interface and IP address to the device. If False no management interfaces will be created, nor will the IP address be added to NetBox, while the device will still get added. 69 | - `default_device_status` string (default "active"), status assigned to a new device by default (must be lowercase). 70 | - `default_device_role` string (default "network") 71 | - `default_device_role_color` string (default FF0000), color assigned to the device role if it needs to be created. 72 | - `default_management_interface` string (default "PLACEHOLDER"), name of the management interface that will be created, if one can't be identified on the device. 73 | - `default_management_prefix_length` integer ( default 0), length of the prefix that will be used for the management IP address, if the IP can't be found. 74 | - `skip_device_type_on_update` boolean (default False), If True, an existing NetBox device will not get its device type updated. If False, device type will be updated with one discovered on a device. 75 | - `skip_manufacturer_on_update` boolean (default False), If True, an existing NetBox device will not get its manufacturer updated. If False, manufacturer will be updated with one discovered on a device. 76 | - `platform_map` (dictionary), mapping of an **auto-detected** Netmiko platform to the **NetBox slug** name of your Platform. The dictionary should be in the format: 77 | ```python 78 | { 79 | : 80 | } 81 | ``` 82 | - `onboarding_extensions_map` (dictionary), mapping of a NAPALM driver name to the loadable Python module used as an onboarding extension. The dictionary should be in the format: 83 | ```python 84 | { 85 | : 86 | } 87 | ``` 88 | - `object_match_strategy` (string), defines the method for searching models. There are 89 | currently two strategies, strict and loose. Strict has to be a direct match, normally 90 | using a slug. Loose allows a range of search criteria to match a single object. If multiple 91 | objects are returned an error is raised. 92 | 93 | ## Upgrades 94 | 95 | When a new release comes out it may be necessary to run a migration of the database to account for any changes in the data models used by this plugin. Execute the command `python3 manage.py migrate`from the NetBox install `netbox/` directory after updating the package. 96 | 97 | ## Usage 98 | 99 | ### Preparation 100 | 101 | To properly onboard a device, the plugin needs to only know the Site as well as device's primary IP address or DNS Name. 102 | 103 | > For DNS Name Resolution to work, the instance of NetBox must be able to resolve the name of the 104 | > device to IP address. 105 | 106 | Providing other attributes (`Platform`, `Device Type`, `Device Role`) is optional - if any of these attributes is provided, plugin will use provided value for the onboarded device. 107 | If `Platform`, `Device Type` and/or `Device Role` are not provided, the plugin will try to identify these information automatically and, based on the settings, it can create them in NetBox as needed. 108 | > If the Platform is provided, it must point to an existing NetBox Platform. NAPALM driver of this platform will be used only if it is defined for the platform in NetBox. 109 | > To use a preferred NAPALM driver, either define it in NetBox per platform or in the plugins settings under `platform_map` 110 | 111 | ### Onboard a new device 112 | 113 | A new device can be onboarded via : 114 | - A web form `/plugins/onboarding/add/` 115 | - A CSV form to import multiple devices in bulk. `/plugins/onboarding/import/` 116 | - An API, `POST /api/plugins​/onboarding​/onboarding​/` 117 | 118 | During a successful onboarding process, a new device will be created in NetBox with its management interface and its primary IP assigned. The management interface will be discovered on the device based on the IP address provided. 119 | 120 | > By default, the plugin is using the credentials defined in the main `configuration.py` for Napalm (`NAPALM_USERNAME`/`NAPALM_PASSWORD`). It's possible to define specific credentials for each onboarding task. 121 | 122 | ### Consult the status of onboarding tasks 123 | 124 | The status of the onboarding process for each device is maintained is a dedicated table in NetBox and can be retrived : 125 | - Via the UI `/plugins/onboarding/` 126 | - Via the API `GET /api/plugins​/onboarding​/onboarding​/` 127 | 128 | ### API 129 | 130 | The plugin includes 4 API endpoints to manage the onbarding tasks 131 | 132 | ```shell 133 | GET /api/plugins​/onboarding​/onboarding​/ Check status of all onboarding tasks. 134 | POST ​ /api/plugins​/onboarding​/onboarding​/ Onboard a new device 135 | GET ​ /api/plugins​/onboarding​/onboarding​/{id}​/ Check the status of a specific onboarding task 136 | DELETE ​ /api/plugins​/onboarding​/onboarding​/{id}​/ Delete a specific onboarding task 137 | ``` 138 | 139 | ## Contributing 140 | 141 | Pull requests are welcomed and automatically built and tested against multiple version of Python and multiple version of NetBox through TravisCI. 142 | 143 | The project is packaged with a light development environment based on `docker-compose` to help with the local development of the project and to run the tests within TravisCI. 144 | 145 | The project is following Network to Code software development guideline and is leveraging: 146 | - Black, Pylint, Bandit and pydocstyle for Python linting and formatting. 147 | - Django unit test to ensure the plugin is working properly. 148 | 149 | ### CLI Helper Commands 150 | 151 | The project is coming with a CLI helper based on [invoke](http://www.pyinvoke.org/) to help setup the development environment. The commands are listed below in 3 categories `dev environment`, `utility` and `testing`. 152 | 153 | Each command can be executed with `invoke `. All commands support the arguments `--netbox-ver` and `--python-ver` if you want to manually define the version of Python and NetBox to use. Each command also has its own help `invoke --help` 154 | 155 | #### Local dev environment 156 | ``` 157 | build Build all docker images. 158 | debug Start NetBox and its dependencies in debug mode. 159 | destroy Destroy all containers and volumes. 160 | start Start NetBox and its dependencies in detached mode. 161 | stop Stop NetBox and its dependencies. 162 | ``` 163 | 164 | #### Utility 165 | ``` 166 | cli Launch a bash shell inside the running NetBox container. 167 | create-user Create a new user in django (default: admin), will prompt for password. 168 | makemigrations Run Make Migration in Django. 169 | nbshell Launch a nbshell session. 170 | ``` 171 | #### Testing 172 | 173 | ``` 174 | tests Run all tests for this plugin. 175 | pylint Run pylint code analysis. 176 | pydocstyle Run pydocstyle to validate docstring formatting adheres to NTC defined standards. 177 | bandit Run bandit to validate basic static code security analysis. 178 | black Run black to check that Python files adhere to its style standards. 179 | unittest Run Django unit tests for the plugin. 180 | ``` 181 | 182 | ## Questions 183 | 184 | For any questions or comments, please check the [FAQ](FAQ.md) first and feel free to swing by the [Network to Code slack channel](https://networktocode.slack.com/) (channel #networktocode). 185 | Sign up [here](http://slack.networktocode.com/) 186 | 187 | ## Screenshots 188 | 189 | List of Onboarding Tasks 190 | ![Onboarding Tasks](docs/images/onboarding_tasks_view.png) 191 | 192 | CSV form to import multiple devices 193 | ![CSV Form](docs/images/csv_import_view.png) 194 | 195 | Onboard a single device 196 | ![Single Device Form](docs/images/single_device_form.png) 197 | 198 | Menu 199 | ![Menu](docs/images/menu.png) 200 | 201 | -------------------------------------------------------------------------------- /development/Dockerfile: -------------------------------------------------------------------------------- 1 | 2 | ARG python_ver=3.7 3 | FROM python:${python_ver} 4 | 5 | ENV PYTHONUNBUFFERED 1 6 | 7 | RUN mkdir -p /opt 8 | 9 | RUN pip install --upgrade pip\ 10 | && pip install poetry 11 | 12 | # ------------------------------------------------------------------------------------- 13 | # Install NetBox 14 | # ------------------------------------------------------------------------------------- 15 | # Remove redis==3.4.1 from the requirements.txt file as a workaround to #4910 16 | # https://github.com/netbox-community/netbox/issues/4910, required for version 2.8.8 and earlier 17 | ARG netbox_ver=master 18 | RUN git clone --single-branch --branch ${netbox_ver} https://github.com/netbox-community/netbox.git /opt/netbox/ && \ 19 | cd /opt/netbox/ && \ 20 | sed -i '/^redis\=\=/d' /opt/netbox/requirements.txt && \ 21 | pip install -r /opt/netbox/requirements.txt 22 | 23 | # Make the django-debug-toolbar always visible when DEBUG is enabled, 24 | # except when we're running Django unit-tests. 25 | RUN echo "import sys" >> /opt/netbox/netbox/netbox/settings.py && \ 26 | echo "TESTING = len(sys.argv) > 1 and sys.argv[1] == 'test'" >> /opt/netbox/netbox/netbox/settings.py && \ 27 | echo "DEBUG_TOOLBAR_CONFIG = {'SHOW_TOOLBAR_CALLBACK': lambda _: DEBUG and not TESTING }" >> /opt/netbox/netbox/netbox/settings.py 28 | 29 | # Work around https://github.com/rq/django-rq/issues/421 30 | RUN pip install django-rq==2.3.2 31 | 32 | # ------------------------------------------------------------------------------------- 33 | # Install Netbox Plugin 34 | # ------------------------------------------------------------------------------------- 35 | RUN mkdir -p /source 36 | WORKDIR /source 37 | COPY . /source 38 | RUN poetry config virtualenvs.create false \ 39 | && poetry install --no-interaction --no-ansi 40 | 41 | WORKDIR /opt/netbox/netbox/ 42 | -------------------------------------------------------------------------------- /development/configuration.py: -------------------------------------------------------------------------------- 1 | """NetBox configuration.""" 2 | import os 3 | from distutils.util import strtobool 4 | from packaging import version 5 | from django.core.exceptions import ImproperlyConfigured 6 | from .settings import VERSION # pylint: disable=relative-beyond-top-level 7 | 8 | 9 | NETBOX_RELEASE_CURRENT = version.parse(VERSION) 10 | NETBOX_RELEASE_28 = version.parse("2.8") 11 | NETBOX_RELEASE_29 = version.parse("2.9") 12 | NETBOX_RELEASE_212 = version.parse("2.12") 13 | 14 | # Enforce required configuration parameters 15 | for key in [ 16 | "ALLOWED_HOSTS", 17 | "POSTGRES_DB", 18 | "POSTGRES_USER", 19 | "POSTGRES_HOST", 20 | "POSTGRES_PASSWORD", 21 | "REDIS_HOST", 22 | "REDIS_PASSWORD", 23 | "SECRET_KEY", 24 | ]: 25 | if not os.environ.get(key): 26 | raise ImproperlyConfigured(f"Required environment variable {key} is missing.") 27 | 28 | 29 | def is_truthy(arg): 30 | """Convert "truthy" strings into Booleans. 31 | 32 | Examples: 33 | >>> is_truthy('yes') 34 | True 35 | Args: 36 | arg (str): Truthy string (True values are y, yes, t, true, on and 1; false values are n, no, 37 | f, false, off and 0. Raises ValueError if val is anything else. 38 | """ 39 | if isinstance(arg, bool): 40 | return arg 41 | 42 | try: 43 | bool_val = strtobool(arg) 44 | except ValueError: 45 | raise ImproperlyConfigured(f"Unexpected variable value: {arg}") # pylint: disable=raise-missing-from 46 | 47 | return bool(bool_val) 48 | 49 | 50 | # For reference see http://netbox.readthedocs.io/en/latest/configuration/mandatory-settings/ 51 | # Based on https://github.com/digitalocean/netbox/blob/develop/netbox/netbox/configuration.example.py 52 | 53 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 54 | 55 | ######################### 56 | # # 57 | # Required settings # 58 | # # 59 | ######################### 60 | 61 | # This is a list of valid fully-qualified domain names (FQDNs) for the NetBox server. NetBox will not permit write 62 | # access to the server via any other hostnames. The first FQDN in the list will be treated as the preferred name. 63 | # 64 | # Example: ALLOWED_HOSTS = ['netbox.example.com', 'netbox.internal.local'] 65 | ALLOWED_HOSTS = os.environ["ALLOWED_HOSTS"].split(" ") 66 | 67 | # PostgreSQL database configuration. 68 | DATABASE = { 69 | "NAME": os.environ["POSTGRES_DB"], # Database name 70 | "USER": os.environ["POSTGRES_USER"], # PostgreSQL username 71 | "PASSWORD": os.environ["POSTGRES_PASSWORD"], 72 | # PostgreSQL password 73 | "HOST": os.environ["POSTGRES_HOST"], # Database server 74 | "PORT": 5432 if "POSTGRES_PORT" not in os.environ else int(os.environ["POSTGRES_PORT"]), # Database port 75 | } 76 | 77 | # This key is used for secure generation of random numbers and strings. It must never be exposed outside of this file. 78 | # For optimal security, SECRET_KEY should be at least 50 characters in length and contain a mix of letters, numbers, and 79 | # symbols. NetBox will not run without this defined. For more information, see 80 | # https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-SECRET_KEY 81 | SECRET_KEY = os.environ["SECRET_KEY"] 82 | 83 | # Redis database settings. The Redis database is used for caching and background processing such as webhooks 84 | # Seperate sections for webhooks and caching allow for connecting to seperate Redis instances/datbases if desired. 85 | # Full connection details are required in both sections, even if they are the same. 86 | REDIS = { 87 | "caching": { 88 | "HOST": os.environ["REDIS_HOST"], 89 | "PORT": int(os.environ.get("REDIS_PORT", 6379)), 90 | "PASSWORD": os.environ["REDIS_PASSWORD"], 91 | "DATABASE": 1, 92 | "SSL": is_truthy(os.environ.get("REDIS_SSL", False)), 93 | }, 94 | "tasks": { 95 | "HOST": os.environ["REDIS_HOST"], 96 | "PORT": int(os.environ.get("REDIS_PORT", 6379)), 97 | "PASSWORD": os.environ["REDIS_PASSWORD"], 98 | "DATABASE": 0, 99 | "SSL": is_truthy(os.environ.get("REDIS_SSL", False)), 100 | }, 101 | } 102 | 103 | if NETBOX_RELEASE_28 < NETBOX_RELEASE_CURRENT < NETBOX_RELEASE_29: 104 | # NetBox 2.8.x Specific Settings 105 | REDIS["caching"]["DEFAULT_TIMEOUT"] = 300 106 | REDIS["tasks"]["DEFAULT_TIMEOUT"] = 300 107 | elif NETBOX_RELEASE_CURRENT < NETBOX_RELEASE_212: 108 | RQ_DEFAULT_TIMEOUT = 300 109 | else: 110 | raise ImproperlyConfigured(f"Version {NETBOX_RELEASE_CURRENT} of NetBox is unsupported at this time.") 111 | 112 | ######################### 113 | # # 114 | # Optional settings # 115 | # # 116 | ######################### 117 | 118 | # Specify one or more name and email address tuples representing NetBox administrators. These people will be notified of 119 | # application errors (assuming correct email settings are provided). 120 | ADMINS = [ 121 | # ['John Doe', 'jdoe@example.com'], 122 | ] 123 | 124 | # Optionally display a persistent banner at the top and/or bottom of every page. HTML is allowed. To display the same 125 | # content in both banners, define BANNER_TOP and set BANNER_BOTTOM = BANNER_TOP. 126 | BANNER_TOP = os.environ.get("BANNER_TOP", "") 127 | BANNER_BOTTOM = os.environ.get("BANNER_BOTTOM", "") 128 | 129 | # Text to include on the login page above the login form. HTML is allowed. 130 | BANNER_LOGIN = os.environ.get("BANNER_LOGIN", "") 131 | 132 | # Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set: 133 | # BASE_PATH = 'netbox/' 134 | BASE_PATH = os.environ.get("BASE_PATH", "") 135 | 136 | # Maximum number of days to retain logged changes. Set to 0 to retain changes indefinitely. (Default: 90) 137 | CHANGELOG_RETENTION = int(os.environ.get("CHANGELOG_RETENTION", 0)) 138 | 139 | # API Cross-Origin Resource Sharing (CORS) settings. If CORS_ORIGIN_ALLOW_ALL is set to True, all origins will be 140 | # allowed. Otherwise, define a list of allowed origins using either CORS_ORIGIN_WHITELIST or 141 | # CORS_ORIGIN_REGEX_WHITELIST. For more information, see https://github.com/ottoyiu/django-cors-headers 142 | CORS_ORIGIN_ALLOW_ALL = is_truthy(os.environ.get("CORS_ORIGIN_ALLOW_ALL", False)) 143 | CORS_ORIGIN_WHITELIST = [] 144 | CORS_ORIGIN_REGEX_WHITELIST = [] 145 | 146 | # Set to True to enable server debugging. WARNING: Debugging introduces a substantial performance penalty and may reveal 147 | # sensitive information about your installation. Only enable debugging while performing testing. Never enable debugging 148 | # on a production system. 149 | DEBUG = is_truthy(os.environ.get("DEBUG", False)) 150 | DEVELOPER = is_truthy(os.environ.get("DEVELOPER", False)) 151 | 152 | # Email settings 153 | EMAIL = { 154 | "SERVER": os.environ.get("EMAIL_SERVER", "localhost"), 155 | "PORT": int(os.environ.get("EMAIL_PORT", 25)), 156 | "USERNAME": os.environ.get("EMAIL_USERNAME", ""), 157 | "PASSWORD": os.environ.get("EMAIL_PASSWORD", ""), 158 | "TIMEOUT": int(os.environ.get("EMAIL_TIMEOUT", 10)), # seconds 159 | "FROM_EMAIL": os.environ.get("EMAIL_FROM", ""), 160 | } 161 | 162 | # Enforcement of unique IP space can be toggled on a per-VRF basis. 163 | # To enforce unique IP space within the global table (all prefixes and IP addresses not assigned to a VRF), 164 | # set ENFORCE_GLOBAL_UNIQUE to True. 165 | ENFORCE_GLOBAL_UNIQUE = is_truthy(os.environ.get("ENFORCE_GLOBAL_UNIQUE", False)) 166 | 167 | # HTTP proxies NetBox should use when sending outbound HTTP requests (e.g. for webhooks). 168 | # HTTP_PROXIES = { 169 | # "http": "http://192.0.2.1:3128", 170 | # "https": "http://192.0.2.1:1080", 171 | # } 172 | 173 | # IP addresses recognized as internal to the system. The debugging toolbar will be available only to clients accessing 174 | # NetBox from an internal IP. 175 | INTERNAL_IPS = ("127.0.0.1", "::1") 176 | 177 | LOG_LEVEL = os.environ.get("LOG_LEVEL", "DEBUG" if DEBUG else "INFO") 178 | 179 | # Enable custom logging. Please see the Django documentation for detailed guidance on configuring custom logs: 180 | # https://docs.djangoproject.com/en/1.11/topics/logging/ 181 | LOGGING = { 182 | "version": 1, 183 | "disable_existing_loggers": False, 184 | "formatters": { 185 | "verbose": { 186 | "format": "{asctime} {levelname} {message} - {name} - {module} - {pathname}:{lineno}", 187 | "datefmt": "%H:%M:%S", 188 | "style": "{", 189 | }, 190 | }, 191 | "handlers": {"console": {"level": "DEBUG", "class": "rq.utils.ColorizingStreamHandler", "formatter": "verbose"}}, 192 | "root": {"handlers": ["console"], "level": LOG_LEVEL}, 193 | } 194 | 195 | # Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users 196 | # are permitted to access most data in NetBox (excluding secrets) but not make any changes. 197 | LOGIN_REQUIRED = is_truthy(os.environ.get("LOGIN_REQUIRED", False)) 198 | 199 | # Setting this to True will display a "maintenance mode" banner at the top of every page. 200 | MAINTENANCE_MODE = is_truthy(os.environ.get("MAINTENANCE_MODE", False)) 201 | 202 | # An API consumer can request an arbitrary number of objects =by appending the "limit" parameter to the URL (e.g. 203 | # "?limit=1000"). This setting defines the maximum limit. Setting it to 0 or None will allow an API consumer to request 204 | # all objects by specifying "?limit=0". 205 | MAX_PAGE_SIZE = int(os.environ.get("MAX_PAGE_SIZE", 1000)) 206 | 207 | # The file path where uploaded media such as image attachments are stored. A trailing slash is not needed. Note that 208 | # the default value of this setting is derived from the installed location. 209 | MEDIA_ROOT = os.environ.get("MEDIA_ROOT", os.path.join(BASE_DIR, "media")) 210 | 211 | # Expose Prometheus monitoring metrics at the HTTP endpoint '/metrics' 212 | METRICS_ENABLED = True 213 | 214 | NAPALM_USERNAME = os.environ.get("NAPALM_USERNAME", "") 215 | NAPALM_PASSWORD = os.environ.get("NAPALM_PASSWORD", "") 216 | 217 | # NAPALM timeout (in seconds). (Default: 30) 218 | NAPALM_TIMEOUT = int(os.environ.get("NAPALM_TIMEOUT", 30)) 219 | 220 | # NAPALM optional arguments (see http://napalm.readthedocs.io/en/latest/support/#optional-arguments). Arguments must 221 | # be provided as a dictionary. 222 | NAPALM_ARGS = { 223 | "secret": NAPALM_PASSWORD, 224 | # Include any additional args here 225 | } 226 | 227 | # Determine how many objects to display per page within a list. (Default: 50) 228 | PAGINATE_COUNT = int(os.environ.get("PAGINATE_COUNT", 50)) 229 | 230 | # Enable installed plugins. Add the name of each plugin to the list. 231 | PLUGINS = ["netbox_onboarding"] 232 | 233 | # Plugins configuration settings. These settings are used by various plugins that the user may have installed. 234 | # Each key in the dictionary is the name of an installed plugin and its value is a dictionary of settings. 235 | PLUGINS_CONFIG = {} 236 | 237 | # When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to 238 | # prefer IPv4 instead. 239 | PREFER_IPV4 = is_truthy(os.environ.get("PREFER_IPV4", False)) 240 | 241 | # Remote authentication support 242 | REMOTE_AUTH_ENABLED = False 243 | REMOTE_AUTH_HEADER = "HTTP_REMOTE_USER" 244 | REMOTE_AUTH_AUTO_CREATE_USER = True 245 | REMOTE_AUTH_DEFAULT_GROUPS = [] 246 | 247 | if NETBOX_RELEASE_28 < NETBOX_RELEASE_CURRENT < NETBOX_RELEASE_29: 248 | # NetBox 2.8.x Specific Settings 249 | REMOTE_AUTH_BACKEND = "utilities.auth_backends.RemoteUserBackend" 250 | REMOTE_AUTH_DEFAULT_PERMISSIONS = [] 251 | elif NETBOX_RELEASE_CURRENT < NETBOX_RELEASE_212: 252 | REMOTE_AUTH_BACKEND = "netbox.authentication.RemoteUserBackend" 253 | REMOTE_AUTH_DEFAULT_PERMISSIONS = {} 254 | else: 255 | raise ImproperlyConfigured(f"Version {NETBOX_RELEASE_CURRENT} of NetBox is unsupported at this time.") 256 | 257 | # This determines how often the GitHub API is called to check the latest release of NetBox. Must be at least 1 hour. 258 | RELEASE_CHECK_TIMEOUT = 24 * 3600 259 | 260 | # This repository is used to check whether there is a new release of NetBox available. Set to None to disable the 261 | # version check or use the URL below to check for release in the official NetBox repository. 262 | RELEASE_CHECK_URL = None 263 | # RELEASE_CHECK_URL = 'https://api.github.com/repos/netbox-community/netbox/releases' 264 | 265 | SESSION_FILE_PATH = None 266 | 267 | # The file path where custom reports will be stored. A trailing slash is not needed. Note that the default value of 268 | # this setting is derived from the installed location. 269 | REPORTS_ROOT = os.environ.get("REPORTS_ROOT", os.path.join(BASE_DIR, "reports")) 270 | 271 | # The file path where custom scripts will be stored. A trailing slash is not needed. Note that the default value of 272 | # this setting is derived from the installed location. 273 | SCRIPTS_ROOT = os.environ.get("SCRIPTS_ROOT", os.path.join(BASE_DIR, "scripts")) 274 | 275 | # Time zone (default: UTC) 276 | TIME_ZONE = os.environ.get("TIME_ZONE", "UTC") 277 | 278 | # Date/time formatting. See the following link for supported formats: 279 | # https://docs.djangoproject.com/en/dev/ref/templates/builtins/#date 280 | DATE_FORMAT = os.environ.get("DATE_FORMAT", "N j, Y") 281 | SHORT_DATE_FORMAT = os.environ.get("SHORT_DATE_FORMAT", "Y-m-d") 282 | TIME_FORMAT = os.environ.get("TIME_FORMAT", "g:i a") 283 | SHORT_TIME_FORMAT = os.environ.get("SHORT_TIME_FORMAT", "H:i:s") 284 | DATETIME_FORMAT = os.environ.get("DATETIME_FORMAT", "N j, Y g:i a") 285 | SHORT_DATETIME_FORMAT = os.environ.get("SHORT_DATETIME_FORMAT", "Y-m-d H:i") 286 | -------------------------------------------------------------------------------- /development/dev.env: -------------------------------------------------------------------------------- 1 | ALLOWED_HOSTS=* 2 | BANNER_TOP="Onboarding plugin dev" 3 | CHANGELOG_RETENTION=0 4 | DEBUG=True 5 | DEVELOPER=True 6 | EMAIL_FROM=netbox@example.com 7 | EMAIL_PASSWORD= 8 | EMAIL_PORT=25 9 | EMAIL_SERVER=localhost 10 | EMAIL_TIMEOUT=5 11 | EMAIL_USERNAME=netbox 12 | MAX_PAGE_SIZE=0 13 | METRICS_ENABLED=True 14 | NAPALM_TIMEOUT=5 15 | POSTGRES_DB=netbox 16 | POSTGRES_HOST=postgres 17 | POSTGRES_PASSWORD=notverysecurepwd 18 | POSTGRES_USER=netbox 19 | REDIS_HOST=redis 20 | REDIS_PASSWORD=notverysecurepwd 21 | REDIS_PORT=6379 22 | # REDIS_SSL=True 23 | # Uncomment REDIS_SSL if using SSL 24 | SECRET_KEY=r8OwDznj!!dci#P9ghmRfdu1Ysxm0AiPeDCQhKE+N_rClfWNj 25 | SUPERUSER_API_TOKEN=0123456789abcdef0123456789abcdef01234567 26 | SUPERUSER_EMAIL=admin@example.com 27 | SUPERUSER_NAME=admin 28 | SUPERUSER_PASSWORD=admin -------------------------------------------------------------------------------- /development/docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: '3' 3 | services: 4 | netbox: 5 | build: 6 | context: ../ 7 | dockerfile: development/Dockerfile 8 | image: "ntc-netbox-plugin-onboarding/netbox:${NETBOX_VER}-py${PYTHON_VER}" 9 | command: > 10 | sh -c "python manage.py migrate && 11 | python manage.py runserver 0.0.0.0:8000" 12 | ports: 13 | - "8000:8000" 14 | depends_on: 15 | - postgres 16 | - redis 17 | env_file: 18 | - ./dev.env 19 | volumes: 20 | - ./configuration.py:/opt/netbox/netbox/netbox/configuration.py 21 | - ../:/source 22 | tty: true 23 | worker: 24 | build: 25 | context: ../ 26 | dockerfile: development/Dockerfile 27 | image: "ntc-netbox-plugin-onboarding/netbox:${NETBOX_VER}-py${PYTHON_VER}" 28 | command: sh -c "python manage.py rqworker" 29 | depends_on: 30 | - netbox 31 | env_file: 32 | - ./dev.env 33 | volumes: 34 | - ./configuration.py:/opt/netbox/netbox/netbox/configuration.py 35 | - ../netbox_onboarding:/source/netbox_onboarding 36 | tty: true 37 | postgres: 38 | image: postgres:10 39 | env_file: dev.env 40 | volumes: 41 | - pgdata_netbox_onboarding:/var/lib/postgresql/data 42 | redis: 43 | image: redis:5-alpine 44 | command: 45 | - sh 46 | - -c # this is to evaluate the $REDIS_PASSWORD from the env 47 | - redis-server --appendonly yes --requirepass $$REDIS_PASSWORD ## $$ because of docker-compose 48 | env_file: ./dev.env 49 | volumes: 50 | pgdata_netbox_onboarding: 51 | -------------------------------------------------------------------------------- /docs/examples/example_ios_set_device_role.py: -------------------------------------------------------------------------------- 1 | """Example of custom onboarding class. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | from netbox_onboarding.netbox_keeper import NetboxKeeper 16 | from netbox_onboarding.onboarding.onboarding import Onboarding 17 | 18 | 19 | class MyOnboardingClass(Onboarding): 20 | """Custom onboarding class example. 21 | 22 | Main purpose of this class is to access and modify the onboarding_kwargs. 23 | By accessing the onboarding kwargs, user gains ability to modify 24 | onboarding parameters before the objects are created in NetBox. 25 | 26 | This class adds the get_device_role method that does the static 27 | string comparison and returns the device role. 28 | """ 29 | 30 | def run(self, onboarding_kwargs): 31 | """Ensures network device.""" 32 | # Access hostname from onboarding_kwargs and get device role automatically 33 | device_new_role = self.get_device_role(hostname=onboarding_kwargs["netdev_hostname"]) 34 | 35 | # Update the device role in onboarding kwargs dictionary 36 | onboarding_kwargs["netdev_nb_role_slug"] = device_new_role 37 | 38 | nb_k = NetboxKeeper(**onboarding_kwargs) 39 | nb_k.ensure_device() 40 | 41 | self.created_device = nb_k.device 42 | 43 | @staticmethod 44 | def get_device_role(hostname): 45 | """Returns the device role based on hostname data. 46 | 47 | This is a static analysis of hostname string content only 48 | """ 49 | hostname_lower = hostname.lower() 50 | if ("rtr" in hostname_lower) or ("router" in hostname_lower): 51 | role = "router" 52 | elif ("sw" in hostname_lower) or ("switch" in hostname_lower): 53 | role = "switch" 54 | elif ("fw" in hostname_lower) or ("firewall" in hostname_lower): 55 | role = "firewall" 56 | elif "dc" in hostname_lower: 57 | role = "datacenter" 58 | else: 59 | role = "generic" 60 | 61 | return role 62 | 63 | 64 | class OnboardingDriverExtensions: 65 | """This is an example of a custom onboarding driver extension. 66 | 67 | This extension sets the onboarding_class to MyOnboardingClass, 68 | which is an example class of how to access and modify the device 69 | role automatically through the onboarding process. 70 | """ 71 | 72 | def __init__(self, napalm_device): 73 | """Inits the class.""" 74 | self.napalm_device = napalm_device 75 | self.onboarding_class = MyOnboardingClass 76 | self.ext_result = None 77 | 78 | def get_onboarding_class(self): 79 | """Return onboarding class for IOS driver. 80 | 81 | Currently supported is Standalone Onboarding Process 82 | 83 | Result of this method is used by the OnboardingManager to 84 | initiate the instance of the onboarding class. 85 | """ 86 | return self.onboarding_class 87 | 88 | def get_ext_result(self): 89 | """This method is used to store any object as a return value. 90 | 91 | Result of this method is passed to the onboarding class as 92 | driver_addon_result argument. 93 | 94 | :return: Any() 95 | """ 96 | return self.ext_result 97 | -------------------------------------------------------------------------------- /docs/images/csv_import_view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/networktocode/ntc-netbox-plugin-onboarding/7239b91a3ec5ecdeb6fd2a40234443b1282f6ff2/docs/images/csv_import_view.png -------------------------------------------------------------------------------- /docs/images/menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/networktocode/ntc-netbox-plugin-onboarding/7239b91a3ec5ecdeb6fd2a40234443b1282f6ff2/docs/images/menu.png -------------------------------------------------------------------------------- /docs/images/onboarding_tasks_view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/networktocode/ntc-netbox-plugin-onboarding/7239b91a3ec5ecdeb6fd2a40234443b1282f6ff2/docs/images/onboarding_tasks_view.png -------------------------------------------------------------------------------- /docs/images/single_device_form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/networktocode/ntc-netbox-plugin-onboarding/7239b91a3ec5ecdeb6fd2a40234443b1282f6ff2/docs/images/single_device_form.png -------------------------------------------------------------------------------- /docs/release-notes/version-2.0.md: -------------------------------------------------------------------------------- 1 | # ntc-netbox-plugin-onboarding v2.0 Release Notes 2 | 3 | ## v2.0 4 | 5 | ### Enhancements 6 | 7 | * NetBox 2.9 support - Supported releases 2.8 and 2.9 8 | * Onboarding extensions - Customizable onboarding process through Python modules. 9 | * Onboarding details exposed in a device view - Date, Status, Last success and Latest task id related to the onboarded device are presented under the device view. 10 | * Onboarding task view - Onboarding details exposed in a dedicated view, including NetBox's ChangeLog. 11 | * Onboarding Changelog - Onboarding uses NetBox's ChangeLog to display user and changes made to the Onboarding Task object. 12 | * Skip onboarding feature - New attribute in the OnboardingDevice model allows to skip the onboarding request on devices with disabled onboarding setting. 13 | 14 | ### Bug Fixes 15 | 16 | * Fixed race condition in `worker.py` 17 | * Improved logging 18 | 19 | ### Additional Changes 20 | 21 | * Platform map now includes NAPALM drivers as defined in NetBox 22 | * Tests have been refactored to inherit NetBox's tests 23 | * Onboarding process will update the Device found by the IP-address lookup. In case of no existing device with onboarded IP-address is found in NetBox, onboarding might update the existing NetBox' looking up by network device's hostname. 24 | * Onboarding will raise Exception when `create_device_type_if_missing` is set to `False` for existing Device with DeviceType mismatch (behaviour pre https://github.com/networktocode/ntc-netbox-plugin-onboarding/issues/74) 25 | -------------------------------------------------------------------------------- /netbox_onboarding/__init__.py: -------------------------------------------------------------------------------- 1 | """Plugin declaration for netbox_onboarding. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | __version__ = "2.2.0" 16 | 17 | from extras.plugins import PluginConfig 18 | 19 | 20 | class OnboardingConfig(PluginConfig): 21 | """Plugin configuration for the netbox_onboarding plugin.""" 22 | 23 | name = "netbox_onboarding" 24 | verbose_name = "Device Onboarding" 25 | version = __version__ 26 | author = "Network to Code" 27 | description = "A plugin for NetBox to easily onboard new devices." 28 | base_url = "onboarding" 29 | required_settings = [] 30 | min_version = "2.8.1" 31 | max_version = "2.11.99" 32 | default_settings = { 33 | "create_platform_if_missing": True, 34 | "create_manufacturer_if_missing": True, 35 | "create_device_type_if_missing": True, 36 | "create_device_role_if_missing": True, 37 | "default_device_role": "network", 38 | "default_device_role_color": "FF0000", 39 | "default_management_interface": "PLACEHOLDER", 40 | "default_management_prefix_length": 0, 41 | "default_device_status": "active", 42 | "create_management_interface_if_missing": True, 43 | "skip_device_type_on_update": False, 44 | "skip_manufacturer_on_update": False, 45 | "platform_map": {}, 46 | "onboarding_extensions_map": {"ios": "netbox_onboarding.onboarding_extensions.ios",}, 47 | "object_match_strategy": "loose", 48 | } 49 | caching_config = {} 50 | 51 | 52 | config = OnboardingConfig # pylint:disable=invalid-name 53 | -------------------------------------------------------------------------------- /netbox_onboarding/admin.py: -------------------------------------------------------------------------------- 1 | """Administrative capabilities for netbox_onboarding plugin. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | from django.contrib import admin 15 | from .models import OnboardingTask 16 | 17 | 18 | @admin.register(OnboardingTask) 19 | class OnboardingTaskAdmin(admin.ModelAdmin): 20 | """Administrative view for managing OnboardingTask instances.""" 21 | 22 | list_display = ( 23 | "pk", 24 | "created_device", 25 | "ip_address", 26 | "site", 27 | "role", 28 | "device_type", 29 | "platform", 30 | "status", 31 | "message", 32 | "failed_reason", 33 | "port", 34 | "timeout", 35 | "created", 36 | ) 37 | -------------------------------------------------------------------------------- /netbox_onboarding/api/__init__.py: -------------------------------------------------------------------------------- 1 | """REST API module for netbox_onboarding plugin.""" 2 | -------------------------------------------------------------------------------- /netbox_onboarding/api/serializers.py: -------------------------------------------------------------------------------- 1 | """Model serializers for the netbox_onboarding REST API. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | from rest_framework import serializers 16 | from django_rq import get_queue 17 | 18 | from dcim.models import Site, DeviceRole, Platform 19 | 20 | from netbox_onboarding.models import OnboardingTask 21 | from netbox_onboarding.utils.credentials import Credentials 22 | 23 | 24 | class OnboardingTaskSerializer(serializers.ModelSerializer): 25 | """Serializer for the OnboardingTask model.""" 26 | 27 | site = serializers.SlugRelatedField( 28 | many=False, 29 | read_only=False, 30 | queryset=Site.objects.all(), 31 | slug_field="slug", 32 | required=True, 33 | help_text="NetBox site 'slug' value", 34 | ) 35 | 36 | ip_address = serializers.CharField(required=True, help_text="IP Address to reach device",) 37 | 38 | username = serializers.CharField(required=False, write_only=True, help_text="Device username",) 39 | 40 | password = serializers.CharField(required=False, write_only=True, help_text="Device password",) 41 | 42 | secret = serializers.CharField(required=False, write_only=True, help_text="Device secret password",) 43 | 44 | port = serializers.IntegerField(required=False, help_text="Device PORT to check for online") 45 | 46 | timeout = serializers.IntegerField(required=False, help_text="Timeout (sec) for device connect") 47 | 48 | role = serializers.SlugRelatedField( 49 | many=False, 50 | read_only=False, 51 | queryset=DeviceRole.objects.all(), 52 | slug_field="slug", 53 | required=False, 54 | help_text="NetBox device role 'slug' value", 55 | ) 56 | 57 | device_type = serializers.CharField(required=False, help_text="NetBox device type 'slug' value",) 58 | 59 | platform = serializers.SlugRelatedField( 60 | many=False, 61 | read_only=False, 62 | queryset=Platform.objects.all(), 63 | slug_field="slug", 64 | required=False, 65 | help_text="NetBox Platform 'slug' value", 66 | ) 67 | 68 | created_device = serializers.CharField(required=False, read_only=True, help_text="Created device name",) 69 | 70 | status = serializers.CharField(required=False, read_only=True, help_text="Onboarding Status") 71 | 72 | failed_reason = serializers.CharField(required=False, read_only=True, help_text="Failure reason") 73 | 74 | message = serializers.CharField(required=False, read_only=True, help_text="Status message") 75 | 76 | class Meta: # noqa: D106 "Missing docstring in public nested class" 77 | model = OnboardingTask 78 | fields = [ 79 | "id", 80 | "site", 81 | "ip_address", 82 | "username", 83 | "password", 84 | "secret", 85 | "port", 86 | "timeout", 87 | "role", 88 | "device_type", 89 | "platform", 90 | "created_device", 91 | "status", 92 | "failed_reason", 93 | "message", 94 | ] 95 | 96 | def create(self, validated_data): 97 | """Create an OnboardingTask and enqueue it for processing.""" 98 | # Fields are string-type so default to empty (instead of None) 99 | username = validated_data.pop("username", "") 100 | password = validated_data.pop("password", "") 101 | secret = validated_data.pop("secret", "") 102 | 103 | credentials = Credentials(username=username, password=password, secret=secret,) 104 | 105 | ot = OnboardingTask.objects.create(**validated_data) 106 | 107 | webhook_queue = get_queue("default") 108 | 109 | webhook_queue.enqueue("netbox_onboarding.worker.onboard_device", ot.id, credentials) 110 | 111 | return ot 112 | -------------------------------------------------------------------------------- /netbox_onboarding/api/urls.py: -------------------------------------------------------------------------------- 1 | """REST API URLs for device onboarding. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | from rest_framework import routers 16 | from .views import OnboardingTaskView 17 | 18 | router = routers.DefaultRouter() 19 | 20 | router.register(r"onboarding", OnboardingTaskView) 21 | 22 | urlpatterns = router.urls 23 | -------------------------------------------------------------------------------- /netbox_onboarding/api/views.py: -------------------------------------------------------------------------------- 1 | """Django REST Framework API views for device onboarding. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | # from drf_yasg.openapi import Parameter, TYPE_STRING 16 | # from drf_yasg.utils import swagger_auto_schema 17 | 18 | from rest_framework import mixins, viewsets 19 | 20 | # from rest_framework.decorators import action 21 | # from rest_framework.response import Response 22 | 23 | # from utilities.api import IsAuthenticatedOrLoginNotRequired 24 | 25 | # from dcim.models import Device, Site, Platform, DeviceRole 26 | 27 | from netbox_onboarding.models import OnboardingTask 28 | from netbox_onboarding.filters import OnboardingTaskFilter 29 | 30 | # from netbox_onboarding.choices import OnboardingStatusChoices 31 | from .serializers import OnboardingTaskSerializer 32 | 33 | 34 | class OnboardingTaskView( 35 | mixins.CreateModelMixin, 36 | mixins.ListModelMixin, 37 | mixins.RetrieveModelMixin, 38 | mixins.DestroyModelMixin, 39 | viewsets.GenericViewSet, 40 | ): 41 | """Create, check status of, and delete onboarding tasks. 42 | 43 | In-place updates (PUT, PATCH) of tasks are not permitted. 44 | """ 45 | 46 | queryset = OnboardingTask.objects.all() 47 | filterset_class = OnboardingTaskFilter 48 | serializer_class = OnboardingTaskSerializer 49 | -------------------------------------------------------------------------------- /netbox_onboarding/choices.py: -------------------------------------------------------------------------------- 1 | """ChoiceSet classes for device onboarding. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | from utilities.choices import ChoiceSet 16 | 17 | 18 | class OnboardingStatusChoices(ChoiceSet): 19 | """Valid values for OnboardingTask "status".""" 20 | 21 | STATUS_FAILED = "failed" 22 | STATUS_PENDING = "pending" 23 | STATUS_RUNNING = "running" 24 | STATUS_SUCCEEDED = "succeeded" 25 | STATUS_SKIPPED = "skipped" 26 | 27 | CHOICES = ( 28 | (STATUS_FAILED, "failed"), 29 | (STATUS_PENDING, "pending"), 30 | (STATUS_RUNNING, "running"), 31 | (STATUS_SUCCEEDED, "succeeded"), 32 | (STATUS_SKIPPED, "skipped"), 33 | ) 34 | 35 | 36 | class OnboardingFailChoices(ChoiceSet): 37 | """Valid values for OnboardingTask "failed reason".""" 38 | 39 | FAIL_LOGIN = "fail-login" 40 | FAIL_CONFIG = "fail-config" 41 | FAIL_CONNECT = "fail-connect" 42 | FAIL_EXECUTE = "fail-execute" 43 | FAIL_GENERAL = "fail-general" 44 | FAIL_DNS = "fail-dns" 45 | 46 | CHOICES = ( 47 | (FAIL_LOGIN, "fail-login"), 48 | (FAIL_CONFIG, "fail-config"), 49 | (FAIL_CONNECT, "fail-connect"), 50 | (FAIL_EXECUTE, "fail-execute"), 51 | (FAIL_GENERAL, "fail-general"), 52 | (FAIL_DNS, "fail-dns"), 53 | ) 54 | -------------------------------------------------------------------------------- /netbox_onboarding/constants.py: -------------------------------------------------------------------------------- 1 | """Constants for netbox_onboarding plugin.""" 2 | 3 | NETMIKO_TO_NAPALM_STATIC = { 4 | "cisco_ios": "ios", 5 | "cisco_nxos": "nxos_ssh", 6 | "arista_eos": "eos", 7 | "juniper_junos": "junos", 8 | "cisco_xr": "iosxr", 9 | } 10 | -------------------------------------------------------------------------------- /netbox_onboarding/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exceptions. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | 16 | class OnboardException(Exception): 17 | """A failure occurred during the onboarding process. 18 | 19 | The exception includes a reason "slug" as defined below as well as a humanized message. 20 | """ 21 | 22 | REASONS = ( 23 | "fail-config", # config provided is not valid 24 | "fail-connect", # device is unreachable at IP:PORT 25 | "fail-execute", # unable to execute device/API command 26 | "fail-login", # bad username/password 27 | "fail-dns", # failed to get IP address from name resolution 28 | "fail-general", # other error 29 | ) 30 | 31 | def __init__(self, reason, message, **kwargs): 32 | """Exception Init.""" 33 | super(OnboardException, self).__init__(kwargs) 34 | self.reason = reason 35 | self.message = message 36 | 37 | def __str__(self): 38 | """Exception __str__.""" 39 | return f"{self.__class__.__name__}: {self.reason}: {self.message}" 40 | -------------------------------------------------------------------------------- /netbox_onboarding/filters.py: -------------------------------------------------------------------------------- 1 | """Filtering logic for OnboardingTask instances. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | import django_filters 16 | from django.db.models import Q 17 | 18 | from dcim.models import Site, DeviceRole, Platform 19 | 20 | from .release import NETBOX_RELEASE_CURRENT, NETBOX_RELEASE_211 21 | from .models import OnboardingTask 22 | 23 | 24 | if NETBOX_RELEASE_CURRENT < NETBOX_RELEASE_211: 25 | from utilities.filters import NameSlugSearchFilterSet # pylint: disable=no-name-in-module, import-error 26 | 27 | class FitersetMixin(NameSlugSearchFilterSet): 28 | """FilterSet Mixin.""" 29 | 30 | 31 | else: 32 | from netbox.filtersets import BaseFilterSet # pylint: disable=no-name-in-module, import-error 33 | 34 | class FitersetMixin(BaseFilterSet): 35 | """FilterSet Mixin.""" 36 | 37 | 38 | class OnboardingTaskFilter(FitersetMixin): 39 | """Filter capabilities for OnboardingTask instances.""" 40 | 41 | q = django_filters.CharFilter(method="search", label="Search",) 42 | 43 | site = django_filters.ModelMultipleChoiceFilter( 44 | field_name="site__slug", queryset=Site.objects.all(), to_field_name="slug", label="Site (slug)", 45 | ) 46 | 47 | site_id = django_filters.ModelMultipleChoiceFilter(queryset=Site.objects.all(), label="Site (ID)",) 48 | 49 | platform = django_filters.ModelMultipleChoiceFilter( 50 | field_name="platform__slug", queryset=Platform.objects.all(), to_field_name="slug", label="Platform (slug)", 51 | ) 52 | 53 | role = django_filters.ModelMultipleChoiceFilter( 54 | field_name="role__slug", queryset=DeviceRole.objects.all(), to_field_name="slug", label="Device Role (slug)", 55 | ) 56 | 57 | class Meta: # noqa: D106 "Missing docstring in public nested class" 58 | model = OnboardingTask 59 | fields = ["id", "site", "site_id", "platform", "role", "status", "failed_reason"] 60 | 61 | def search(self, queryset, name, value): # pylint: disable=unused-argument, no-self-use 62 | """Perform the filtered search.""" 63 | if not value.strip(): 64 | return queryset 65 | qs_filter = ( 66 | Q(id__icontains=value) 67 | | Q(ip_address__icontains=value) 68 | | Q(site__name__icontains=value) 69 | | Q(platform__name__icontains=value) 70 | | Q(created_device__name__icontains=value) 71 | | Q(status__icontains=value) 72 | | Q(failed_reason__icontains=value) 73 | | Q(message__icontains=value) 74 | ) 75 | return queryset.filter(qs_filter) 76 | -------------------------------------------------------------------------------- /netbox_onboarding/forms.py: -------------------------------------------------------------------------------- 1 | """Forms for network device onboarding. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | from django import forms 16 | from django.db import transaction 17 | from django_rq import get_queue 18 | 19 | from utilities.forms import BootstrapMixin, CSVModelForm 20 | from dcim.models import Site, Platform, DeviceRole, DeviceType 21 | 22 | from .models import OnboardingTask 23 | from .choices import OnboardingStatusChoices, OnboardingFailChoices 24 | from .utils.credentials import Credentials 25 | 26 | BLANK_CHOICE = (("", "---------"),) 27 | 28 | 29 | class OnboardingTaskForm(BootstrapMixin, forms.ModelForm): 30 | """Form for creating a new OnboardingTask instance.""" 31 | 32 | ip_address = forms.CharField( 33 | required=True, label="IP address", help_text="IP Address/DNS Name of the device to onboard" 34 | ) 35 | 36 | site = forms.ModelChoiceField(required=True, queryset=Site.objects.all()) 37 | 38 | username = forms.CharField(required=False, help_text="Device username (will not be stored in database)") 39 | password = forms.CharField( 40 | required=False, widget=forms.PasswordInput, help_text="Device password (will not be stored in database)" 41 | ) 42 | secret = forms.CharField( 43 | required=False, widget=forms.PasswordInput, help_text="Device secret (will not be stored in database)" 44 | ) 45 | 46 | platform = forms.ModelChoiceField( 47 | queryset=Platform.objects.all(), 48 | required=False, 49 | to_field_name="slug", 50 | help_text="Device platform. Define ONLY to override auto-recognition of platform.", 51 | ) 52 | role = forms.ModelChoiceField( 53 | queryset=DeviceRole.objects.all(), 54 | required=False, 55 | to_field_name="slug", 56 | help_text="Device role. Define ONLY to override auto-recognition of role.", 57 | ) 58 | device_type = forms.ModelChoiceField( 59 | queryset=DeviceType.objects.all(), 60 | required=False, 61 | to_field_name="slug", 62 | help_text="Device type. Define ONLY to override auto-recognition of type.", 63 | ) 64 | 65 | class Meta: # noqa: D106 "Missing docstring in public nested class" 66 | model = OnboardingTask 67 | fields = [ 68 | "site", 69 | "ip_address", 70 | "port", 71 | "timeout", 72 | "username", 73 | "password", 74 | "secret", 75 | "platform", 76 | "role", 77 | "device_type", 78 | ] 79 | 80 | def save(self, commit=True, **kwargs): 81 | """Save the model, and add it and the associated credentials to the onboarding worker queue.""" 82 | model = super().save(commit=commit, **kwargs) 83 | if commit: 84 | credentials = Credentials(self.data.get("username"), self.data.get("password"), self.data.get("secret")) 85 | get_queue("default").enqueue("netbox_onboarding.worker.onboard_device", model.pk, credentials) 86 | return model 87 | 88 | 89 | class OnboardingTaskFilterForm(BootstrapMixin, forms.ModelForm): 90 | """Form for filtering OnboardingTask instances.""" 91 | 92 | site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, to_field_name="slug") 93 | 94 | platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False, to_field_name="slug") 95 | 96 | status = forms.ChoiceField(choices=BLANK_CHOICE + OnboardingStatusChoices.CHOICES, required=False) 97 | 98 | failed_reason = forms.ChoiceField( 99 | choices=BLANK_CHOICE + OnboardingFailChoices.CHOICES, required=False, label="Failed Reason" 100 | ) 101 | 102 | q = forms.CharField(required=False, label="Search") 103 | 104 | class Meta: # noqa: D106 "Missing docstring in public nested class" 105 | model = OnboardingTask 106 | fields = ["q", "site", "platform", "status", "failed_reason"] 107 | 108 | 109 | class OnboardingTaskFeedCSVForm(CSVModelForm): 110 | """Form for entering CSV to bulk-import OnboardingTask entries.""" 111 | 112 | site = forms.ModelChoiceField( 113 | queryset=Site.objects.all(), 114 | required=True, 115 | to_field_name="slug", 116 | help_text="Slug of parent site", 117 | error_messages={"invalid_choice": "Site not found",}, 118 | ) 119 | ip_address = forms.CharField(required=True, help_text="IP Address of the onboarded device") 120 | username = forms.CharField(required=False, help_text="Username, will not be stored in database") 121 | password = forms.CharField(required=False, help_text="Password, will not be stored in database") 122 | secret = forms.CharField(required=False, help_text="Secret password, will not be stored in database") 123 | platform = forms.ModelChoiceField( 124 | queryset=Platform.objects.all(), 125 | required=False, 126 | to_field_name="slug", 127 | help_text="Slug of device platform. Define ONLY to override auto-recognition of platform.", 128 | error_messages={"invalid_choice": "Platform not found.",}, 129 | ) 130 | port = forms.IntegerField(required=False, help_text="Device PORT (def: 22)",) 131 | 132 | timeout = forms.IntegerField(required=False, help_text="Device Timeout (sec) (def: 30)",) 133 | 134 | role = forms.ModelChoiceField( 135 | queryset=DeviceRole.objects.all(), 136 | required=False, 137 | to_field_name="slug", 138 | help_text="Slug of device role. Define ONLY to override auto-recognition of role.", 139 | error_messages={"invalid_choice": "DeviceRole not found",}, 140 | ) 141 | 142 | device_type = forms.ModelChoiceField( 143 | queryset=DeviceType.objects.all(), 144 | required=False, 145 | to_field_name="slug", 146 | help_text="Slug of device type. Define ONLY to override auto-recognition of type.", 147 | error_messages={"invalid_choice": "DeviceType not found",}, 148 | ) 149 | 150 | class Meta: # noqa: D106 "Missing docstring in public nested class" 151 | model = OnboardingTask 152 | fields = [ 153 | "site", 154 | "ip_address", 155 | "port", 156 | "timeout", 157 | "platform", 158 | "role", 159 | ] 160 | 161 | def save(self, commit=True, **kwargs): 162 | """Save the model, and add it and the associated credentials to the onboarding worker queue.""" 163 | model = super().save(commit=commit, **kwargs) 164 | if commit: 165 | credentials = Credentials(self.data.get("username"), self.data.get("password"), self.data.get("secret")) 166 | transaction.on_commit( 167 | lambda: get_queue("default").enqueue("netbox_onboarding.worker.onboard_device", model.pk, credentials) 168 | ) 169 | return model 170 | -------------------------------------------------------------------------------- /netbox_onboarding/helpers.py: -------------------------------------------------------------------------------- 1 | """OnboardingTask Django model. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | import socket 16 | 17 | import netaddr 18 | from netaddr.core import AddrFormatError 19 | 20 | from .exceptions import OnboardException 21 | 22 | 23 | def onboarding_task_fqdn_to_ip(ot): 24 | """Method to assure OT has FQDN resolved to IP address and rewritten into OT. 25 | 26 | If it is a DNS name, attempt to resolve the DNS address and assign the IP address to the 27 | name. 28 | 29 | Returns: 30 | None 31 | 32 | Raises: 33 | OnboardException("fail-general"): 34 | When a prefix was entered for an IP address 35 | OnboardException("fail-dns"): 36 | When a Name lookup via DNS fails to resolve an IP address 37 | """ 38 | try: 39 | # If successful, this is an IP address and can pass 40 | netaddr.IPAddress(ot.ip_address) 41 | # Raise an Exception for Prefix values 42 | except ValueError: 43 | raise OnboardException(reason="fail-general", message=f"ERROR appears a prefix was entered: {ot.ip_address}") 44 | # An AddrFormatError exception means that there is not an IP address in the field, and should continue on 45 | except AddrFormatError: 46 | try: 47 | # Perform DNS Lookup 48 | ot.ip_address = socket.gethostbyname(ot.ip_address) 49 | ot.save() 50 | except socket.gaierror: 51 | # DNS Lookup has failed, Raise an exception for unable to complete DNS lookup 52 | raise OnboardException(reason="fail-dns", message=f"ERROR failed to complete DNS lookup: {ot.ip_address}") 53 | -------------------------------------------------------------------------------- /netbox_onboarding/metrics.py: -------------------------------------------------------------------------------- 1 | """Plugin additions to the NetBox navigation menu. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | from prometheus_client import Counter 15 | 16 | onboardingtask_results_counter = Counter( 17 | name="onboardingtask_results_total", documentation="Count of results for Onboarding Task", labelnames=("status",) 18 | ) 19 | -------------------------------------------------------------------------------- /netbox_onboarding/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-05-18 21:58 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ("dcim", "0105_interface_name_collation"), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="OnboardingTask", 18 | fields=[ 19 | ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False)), 20 | ("ip_address", models.CharField(max_length=255, null=True)), 21 | ("device_type", models.CharField(max_length=255, null=True)), 22 | ("status", models.CharField(max_length=255)), 23 | ("failed_reason", models.CharField(max_length=255, null=True)), 24 | ("message", models.CharField(blank=True, max_length=511)), 25 | ("port", models.PositiveSmallIntegerField(default=22)), 26 | ("timeout", models.PositiveSmallIntegerField(default=30)), 27 | ("created_on", models.DateTimeField(auto_now_add=True)), 28 | ( 29 | "created_device", 30 | models.ForeignKey( 31 | blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to="dcim.Device" 32 | ), 33 | ), 34 | ( 35 | "platform", 36 | models.ForeignKey( 37 | blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to="dcim.Platform" 38 | ), 39 | ), 40 | ( 41 | "role", 42 | models.ForeignKey( 43 | blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to="dcim.DeviceRole" 44 | ), 45 | ), 46 | ( 47 | "site", 48 | models.ForeignKey( 49 | blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to="dcim.Site" 50 | ), 51 | ), 52 | ], 53 | options={"ordering": ["created_on"],}, 54 | ), 55 | ] 56 | -------------------------------------------------------------------------------- /netbox_onboarding/migrations/0002_onboardingdevice.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.10 on 2020-08-21 11:05 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("netbox_onboarding", "0001_initial"), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="OnboardingDevice", 16 | fields=[ 17 | ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False)), 18 | ("enabled", models.BooleanField(default=True)), 19 | ("device", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to="dcim.Device")), 20 | ], 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /netbox_onboarding/migrations/0003_onboardingtask_change_logging_model.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | 6 | dependencies = [ 7 | ("netbox_onboarding", "0002_onboardingdevice"), 8 | ] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="onboardingtask", name="created", field=models.DateField(auto_now_add=True, null=True), 13 | ), 14 | migrations.AddField( 15 | model_name="onboardingtask", name="last_updated", field=models.DateTimeField(auto_now=True, null=True), 16 | ), 17 | migrations.AlterModelOptions(name="onboardingtask", options={},), 18 | migrations.RemoveField(model_name="onboardingtask", name="created_on",), 19 | ] 20 | -------------------------------------------------------------------------------- /netbox_onboarding/migrations/0004_create_onboardingdevice.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | def create_missing_onboardingdevice(apps, schema_editor): 5 | Device = apps.get_model("dcim", "Device") 6 | OnboardingDevice = apps.get_model("netbox_onboarding", "OnboardingDevice") 7 | 8 | for device in Device.objects.filter(onboardingdevice__isnull=True): 9 | OnboardingDevice.objects.create(device=device) 10 | 11 | 12 | class Migration(migrations.Migration): 13 | 14 | dependencies = [ 15 | ("netbox_onboarding", "0003_onboardingtask_change_logging_model"), 16 | ] 17 | 18 | operations = [ 19 | migrations.RunPython(create_missing_onboardingdevice), 20 | ] 21 | -------------------------------------------------------------------------------- /netbox_onboarding/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/networktocode/ntc-netbox-plugin-onboarding/7239b91a3ec5ecdeb6fd2a40234443b1282f6ff2/netbox_onboarding/migrations/__init__.py -------------------------------------------------------------------------------- /netbox_onboarding/models.py: -------------------------------------------------------------------------------- 1 | """OnboardingTask Django model. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | from django.db.models.signals import post_save 15 | from django.dispatch import receiver 16 | from django.db import models 17 | from django.urls import reverse 18 | from dcim.models import Device 19 | from .choices import OnboardingStatusChoices, OnboardingFailChoices 20 | from .release import NETBOX_RELEASE_CURRENT, NETBOX_RELEASE_29, NETBOX_RELEASE_211 21 | 22 | # Support NetBox 2.8 23 | if NETBOX_RELEASE_CURRENT < NETBOX_RELEASE_29: 24 | from utilities.models import ChangeLoggedModel # pylint: disable=no-name-in-module, import-error 25 | # Support NetBox 2.9, NetBox 2.10 26 | elif NETBOX_RELEASE_CURRENT < NETBOX_RELEASE_211: 27 | from extras.models import ChangeLoggedModel # pylint: disable=no-name-in-module, import-error 28 | # Support NetBox 2.11 29 | else: 30 | from netbox.models import ChangeLoggedModel # pylint: disable=no-name-in-module, import-error 31 | 32 | 33 | class OnboardingTask(ChangeLoggedModel): 34 | """The status of each onboarding Task is tracked in the OnboardingTask table.""" 35 | 36 | created_device = models.ForeignKey(to="dcim.Device", on_delete=models.SET_NULL, blank=True, null=True) 37 | 38 | ip_address = models.CharField(max_length=255, help_text="primary ip address for the device", null=True) 39 | 40 | site = models.ForeignKey(to="dcim.Site", on_delete=models.SET_NULL, blank=True, null=True) 41 | 42 | role = models.ForeignKey(to="dcim.DeviceRole", on_delete=models.SET_NULL, blank=True, null=True) 43 | 44 | device_type = models.CharField( 45 | null=True, max_length=255, help_text="Device Type extracted from the device (optional)" 46 | ) 47 | 48 | platform = models.ForeignKey(to="dcim.Platform", on_delete=models.SET_NULL, blank=True, null=True) 49 | 50 | status = models.CharField(max_length=255, choices=OnboardingStatusChoices, help_text="Overall status of the task") 51 | 52 | failed_reason = models.CharField( 53 | max_length=255, choices=OnboardingFailChoices, help_text="Raison why the task failed (optional)", null=True 54 | ) 55 | 56 | message = models.CharField(max_length=511, blank=True) 57 | 58 | port = models.PositiveSmallIntegerField(help_text="Port to use to connect to the device", default=22) 59 | timeout = models.PositiveSmallIntegerField( 60 | help_text="Timeout period in sec to wait while connecting to the device", default=30 61 | ) 62 | 63 | def __str__(self): 64 | """String representation of an OnboardingTask.""" 65 | return f"{self.site} : {self.ip_address}" 66 | 67 | def get_absolute_url(self): 68 | """Provide absolute URL to an OnboardingTask.""" 69 | return reverse("plugins:netbox_onboarding:onboardingtask", kwargs={"pk": self.pk}) 70 | 71 | if NETBOX_RELEASE_CURRENT >= NETBOX_RELEASE_29: 72 | from utilities.querysets import RestrictedQuerySet # pylint: disable=no-name-in-module, import-outside-toplevel 73 | 74 | objects = RestrictedQuerySet.as_manager() 75 | 76 | 77 | class OnboardingDevice(models.Model): 78 | """The status of each Onboarded Device is tracked in the OnboardingDevice table.""" 79 | 80 | device = models.OneToOneField(to="dcim.Device", on_delete=models.CASCADE) 81 | enabled = models.BooleanField(default=True, help_text="Whether (re)onboarding of this device is permitted") 82 | 83 | @property 84 | def last_check_attempt_date(self): 85 | """Date of last onboarding attempt for a device.""" 86 | if self.device.primary_ip4: 87 | try: 88 | return ( 89 | OnboardingTask.objects.filter( 90 | ip_address=self.device.primary_ip4.address.ip.format() # pylint: disable=no-member 91 | ) 92 | .latest("last_updated") 93 | .created 94 | ) 95 | except OnboardingTask.DoesNotExist: 96 | return "unknown" 97 | else: 98 | return "unknown" 99 | 100 | @property 101 | def last_check_successful_date(self): 102 | """Date of last successful onboarding for a device.""" 103 | if self.device.primary_ip4: 104 | try: 105 | return ( 106 | OnboardingTask.objects.filter( 107 | ip_address=self.device.primary_ip4.address.ip.format(), # pylint: disable=no-member 108 | status=OnboardingStatusChoices.STATUS_SUCCEEDED, 109 | ) 110 | .latest("last_updated") 111 | .created 112 | ) 113 | except OnboardingTask.DoesNotExist: 114 | return "unknown" 115 | else: 116 | return "unknown" 117 | 118 | @property 119 | def status(self): 120 | """Last onboarding status.""" 121 | if self.device.primary_ip4: 122 | try: 123 | return ( 124 | OnboardingTask.objects.filter( 125 | ip_address=self.device.primary_ip4.address.ip.format() # pylint: disable=no-member 126 | ) 127 | .latest("last_updated") 128 | .status 129 | ) 130 | except OnboardingTask.DoesNotExist: 131 | return "unknown" 132 | else: 133 | return "unknown" 134 | 135 | @property 136 | def last_ot(self): 137 | """Last onboarding task.""" 138 | if self.device.primary_ip4: 139 | try: 140 | return OnboardingTask.objects.filter( 141 | ip_address=self.device.primary_ip4.address.ip.format() # pylint: disable=no-member 142 | ).latest("last_updated") 143 | except OnboardingTask.DoesNotExist: 144 | return "unknown" 145 | else: 146 | return "unknown" 147 | 148 | 149 | @receiver(post_save, sender=Device) 150 | def init_onboarding_for_new_device(sender, instance, created, **kwargs): # pylint: disable=unused-argument 151 | """Register to create a OnboardingDevice object for each new Device Object using Django Signal. 152 | 153 | https://docs.djangoproject.com/en/3.0/ref/signals/#post-save 154 | """ 155 | if created: 156 | OnboardingDevice.objects.create(device=instance) 157 | -------------------------------------------------------------------------------- /netbox_onboarding/navigation.py: -------------------------------------------------------------------------------- 1 | """Plugin additions to the NetBox navigation menu. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | from extras.plugins import PluginMenuButton, PluginMenuItem 16 | from utilities.choices import ButtonColorChoices 17 | 18 | from .release import NETBOX_RELEASE_CURRENT, NETBOX_RELEASE_210 19 | 20 | menu_items = ( 21 | PluginMenuItem( 22 | link="plugins:netbox_onboarding:onboardingtask_list", 23 | link_text="Onboarding Tasks", 24 | permissions=["netbox_onboarding.view_onboardingtask"], 25 | buttons=( 26 | PluginMenuButton( 27 | link="plugins:netbox_onboarding:onboardingtask_add", 28 | title="Onboard", 29 | icon_class="mdi mdi-plus-thick" if NETBOX_RELEASE_CURRENT >= NETBOX_RELEASE_210 else "fa fa-plus", 30 | color=ButtonColorChoices.GREEN, 31 | permissions=["netbox_onboarding.add_onboardingtask"], 32 | ), 33 | PluginMenuButton( 34 | link="plugins:netbox_onboarding:onboardingtask_import", 35 | title="Bulk Onboard", 36 | icon_class="mdi mdi-database-import-outline" 37 | if NETBOX_RELEASE_CURRENT >= NETBOX_RELEASE_210 38 | else "fa fa-download", 39 | color=ButtonColorChoices.BLUE, 40 | permissions=["netbox_onboarding.add_onboardingtask"], 41 | ), 42 | ), 43 | ), 44 | ) 45 | -------------------------------------------------------------------------------- /netbox_onboarding/netbox_keeper.py: -------------------------------------------------------------------------------- 1 | """NetBox Keeper. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | import logging 16 | import re 17 | 18 | from django.conf import settings 19 | from django.utils.text import slugify 20 | from dcim.models import Manufacturer, Device, Interface, DeviceType, DeviceRole 21 | from dcim.models import Platform 22 | from dcim.models import Site 23 | from ipam.models import IPAddress 24 | 25 | from .constants import NETMIKO_TO_NAPALM_STATIC 26 | from .exceptions import OnboardException 27 | 28 | logger = logging.getLogger("rq.worker") 29 | 30 | PLUGIN_SETTINGS = settings.PLUGINS_CONFIG["netbox_onboarding"] 31 | 32 | 33 | def object_match(obj, search_array): 34 | """Used to search models for multiple criteria. 35 | 36 | Inputs: 37 | obj: The model used for searching. 38 | search_array: Nested dictionaries used to search models. First criteria will be used 39 | for strict searching. Loose searching will loop through the search_array 40 | until it finds a match. Example below. 41 | [ 42 | {"slug__iexact": 'switch1'}, 43 | {"model__iexact": 'Cisco'} 44 | ] 45 | """ 46 | try: 47 | result = obj.objects.get(**search_array[0]) 48 | return result 49 | except obj.DoesNotExist: 50 | if PLUGIN_SETTINGS["object_match_strategy"] == "loose": 51 | for search_array_element in search_array[1:]: 52 | try: 53 | result = obj.objects.get(**search_array_element) 54 | return result 55 | except obj.DoesNotExist: 56 | pass 57 | except obj.MultipleObjectsReturned: 58 | raise OnboardException( 59 | reason="fail-general", 60 | message=f"ERROR multiple objects found in {str(obj)} searching on {str(search_array_element)})", 61 | ) 62 | raise 63 | except obj.MultipleObjectsReturned: 64 | raise OnboardException( 65 | reason="fail-general", 66 | message=f"ERROR multiple objects found in {str(obj)} searching on {str(search_array_element)})", 67 | ) 68 | 69 | 70 | class NetboxKeeper: 71 | """Used to manage the information relating to the network device within the NetBox server.""" 72 | 73 | def __init__( # pylint: disable=R0913,R0914 74 | self, 75 | netdev_hostname, 76 | netdev_nb_role_slug, 77 | netdev_vendor, 78 | netdev_nb_site_slug, 79 | netdev_nb_device_type_slug=None, 80 | netdev_model=None, 81 | netdev_nb_role_color=None, 82 | netdev_mgmt_ip_address=None, 83 | netdev_nb_platform_slug=None, 84 | netdev_serial_number=None, 85 | netdev_mgmt_ifname=None, 86 | netdev_mgmt_pflen=None, 87 | netdev_netmiko_device_type=None, 88 | onboarding_class=None, 89 | driver_addon_result=None, 90 | ): 91 | """Create an instance and initialize the managed attributes that are used throughout the onboard processing. 92 | 93 | Args: 94 | netdev_hostname (str): NetBox's device name 95 | netdev_nb_role_slug (str): NetBox's device role slug 96 | netdev_vendor (str): Device's vendor name 97 | netdev_nb_site_slug (str): Device site's slug 98 | netdev_nb_device_type_slug (str): Device type's slug 99 | netdev_model (str): Device's model 100 | netdev_nb_role_color (str): NetBox device's role color 101 | netdev_mgmt_ip_address (str): IPv4 Address of a device 102 | netdev_nb_platform_slug (str): NetBox device's platform slug 103 | netdev_serial_number (str): Device's serial number 104 | netdev_mgmt_ifname (str): Device's management interface name 105 | netdev_mgmt_pflen (str): Device's management IP prefix-len 106 | netdev_netmiko_device_type (str): Device's Netmiko device type 107 | onboarding_class (Object): Onboarding Class (future use) 108 | driver_addon_result (Any): Attached extended result (future use) 109 | """ 110 | self.netdev_mgmt_ip_address = netdev_mgmt_ip_address 111 | self.netdev_nb_site_slug = netdev_nb_site_slug 112 | self.netdev_nb_device_type_slug = netdev_nb_device_type_slug 113 | self.netdev_nb_role_slug = netdev_nb_role_slug 114 | self.netdev_nb_role_color = netdev_nb_role_color 115 | self.netdev_nb_platform_slug = netdev_nb_platform_slug 116 | 117 | self.netdev_hostname = netdev_hostname 118 | self.netdev_vendor = netdev_vendor 119 | self.netdev_model = netdev_model 120 | self.netdev_serial_number = netdev_serial_number 121 | self.netdev_mgmt_ifname = netdev_mgmt_ifname 122 | self.netdev_mgmt_pflen = netdev_mgmt_pflen 123 | self.netdev_netmiko_device_type = netdev_netmiko_device_type 124 | 125 | self.onboarding_class = onboarding_class 126 | self.driver_addon_result = driver_addon_result 127 | 128 | # these attributes are netbox model instances as discovered/created 129 | # through the course of processing. 130 | self.nb_site = None 131 | self.nb_manufacturer = None 132 | self.nb_device_type = None 133 | self.nb_device_role = None 134 | self.nb_platform = None 135 | 136 | self.device = None 137 | self.onboarded_device = None 138 | self.nb_mgmt_ifname = None 139 | self.nb_primary_ip = None 140 | 141 | def ensure_onboarded_device(self): 142 | """Lookup if the device already exists in the NetBox. 143 | 144 | Lookup is performed by querying for the IP address of the onboarded device. 145 | If the device with a given IP is already in NetBox, its attributes including name could be updated 146 | """ 147 | try: 148 | if self.netdev_mgmt_ip_address: 149 | self.onboarded_device = Device.objects.get(primary_ip4__address__net_host=self.netdev_mgmt_ip_address) 150 | except Device.DoesNotExist: 151 | logger.info( 152 | "Could not find existing NetBox device for requested primary IP address (%s)", 153 | self.netdev_mgmt_ip_address, 154 | ) 155 | except Device.MultipleObjectsReturned: 156 | raise OnboardException( 157 | reason="fail-general", 158 | message=f"ERROR multiple devices using same IP in NetBox: {self.netdev_mgmt_ip_address}", 159 | ) 160 | 161 | def ensure_device_site(self): 162 | """Ensure device's site.""" 163 | try: 164 | self.nb_site = Site.objects.get(slug=self.netdev_nb_site_slug) 165 | except Site.DoesNotExist: 166 | raise OnboardException(reason="fail-config", message=f"Site not found: {self.netdev_nb_site_slug}") 167 | 168 | def ensure_device_manufacturer( 169 | self, 170 | create_manufacturer=PLUGIN_SETTINGS["create_manufacturer_if_missing"], 171 | skip_manufacturer_on_update=PLUGIN_SETTINGS["skip_manufacturer_on_update"], 172 | ): 173 | """Ensure device's manufacturer.""" 174 | # Support to skip manufacturer updates for existing devices 175 | if self.onboarded_device and skip_manufacturer_on_update: 176 | self.nb_manufacturer = self.onboarded_device.device_type.manufacturer 177 | 178 | return 179 | 180 | # First ensure that the vendor, as extracted from the network device exists 181 | # in NetBox. We need the ID for this vendor when ensuring the DeviceType 182 | # instance. 183 | 184 | nb_manufacturer_slug = slugify(self.netdev_vendor) 185 | 186 | try: 187 | search_array = [{"slug__iexact": nb_manufacturer_slug}] 188 | self.nb_manufacturer = object_match(Manufacturer, search_array) 189 | except Manufacturer.DoesNotExist: 190 | if create_manufacturer: 191 | self.nb_manufacturer = Manufacturer.objects.create(name=self.netdev_vendor, slug=nb_manufacturer_slug) 192 | else: 193 | raise OnboardException( 194 | reason="fail-config", message=f"ERROR manufacturer not found: {self.netdev_vendor}" 195 | ) 196 | 197 | def ensure_device_type( 198 | self, 199 | create_device_type=PLUGIN_SETTINGS["create_device_type_if_missing"], 200 | skip_device_type_on_update=PLUGIN_SETTINGS["skip_device_type_on_update"], 201 | ): 202 | """Ensure the Device Type (slug) exists in NetBox associated to the netdev "model" and "vendor" (manufacturer). 203 | 204 | Args: 205 | create_device_type (bool): Flag to indicate if we need to create the device_type, if not already present 206 | skip_device_type_on_update (bool): Flag to indicate if we skip device type updates for existing devices 207 | Raises: 208 | OnboardException('fail-config'): 209 | When the device vendor value does not exist as a Manufacturer in 210 | NetBox. 211 | 212 | OnboardException('fail-config'): 213 | When the device-type exists by slug, but is assigned to a different 214 | manufacturer. This should *not* happen, but guard-rail checking 215 | regardless in case two vendors have the same model name. 216 | """ 217 | # Support to skip device type updates for existing devices 218 | if self.onboarded_device and skip_device_type_on_update: 219 | self.nb_device_type = self.onboarded_device.device_type 220 | 221 | return 222 | 223 | # Now see if the device type (slug) already exists, 224 | # if so check to make sure that it is not assigned as a different manufacturer 225 | # if it doesn't exist, create it if the flag 'create_device_type_if_missing' is defined 226 | 227 | slug = self.netdev_model 228 | if self.netdev_model and re.search(r"[^a-zA-Z0-9\-_]+", slug): 229 | logger.warning("device model is not sluggable: %s", slug) 230 | self.netdev_model = slug.replace(" ", "-") 231 | logger.warning("device model is now: %s", self.netdev_model) 232 | 233 | # Use declared device type or auto-discovered model 234 | nb_device_type_text = self.netdev_nb_device_type_slug or self.netdev_model 235 | 236 | if not nb_device_type_text: 237 | raise OnboardException(reason="fail-config", message="ERROR device type not found") 238 | 239 | nb_device_type_slug = slugify(nb_device_type_text) 240 | 241 | try: 242 | search_array = [ 243 | {"slug__iexact": nb_device_type_slug}, 244 | {"model__iexact": self.netdev_model}, 245 | {"part_number__iexact": self.netdev_model}, 246 | ] 247 | 248 | self.nb_device_type = object_match(DeviceType, search_array) 249 | 250 | if self.nb_device_type.manufacturer.id != self.nb_manufacturer.id: 251 | raise OnboardException( 252 | reason="fail-config", 253 | message=f"ERROR device type {self.netdev_model} " f"already exists for vendor {self.netdev_vendor}", 254 | ) 255 | 256 | except DeviceType.DoesNotExist: 257 | if create_device_type: 258 | logger.info("CREATE: device-type: %s", self.netdev_model) 259 | self.nb_device_type = DeviceType.objects.create( 260 | slug=nb_device_type_slug, model=nb_device_type_slug.upper(), manufacturer=self.nb_manufacturer, 261 | ) 262 | else: 263 | raise OnboardException( 264 | reason="fail-config", message=f"ERROR device type not found: {self.netdev_model}" 265 | ) 266 | 267 | def ensure_device_role( 268 | self, create_device_role=PLUGIN_SETTINGS["create_device_role_if_missing"], 269 | ): 270 | """Ensure that the device role is defined / exist in NetBox or create it if it doesn't exist. 271 | 272 | Args: 273 | create_device_role (bool) :Flag to indicate if we need to create the device_role, if not already present 274 | Raises: 275 | OnboardException('fail-config'): 276 | When the device role value does not exist 277 | NetBox. 278 | """ 279 | try: 280 | self.nb_device_role = DeviceRole.objects.get(slug=self.netdev_nb_role_slug) 281 | except DeviceRole.DoesNotExist: 282 | if create_device_role: 283 | self.nb_device_role = DeviceRole.objects.create( 284 | name=self.netdev_nb_role_slug, 285 | slug=self.netdev_nb_role_slug, 286 | color=self.netdev_nb_role_color, 287 | vm_role=False, 288 | ) 289 | else: 290 | raise OnboardException( 291 | reason="fail-config", message=f"ERROR device role not found: {self.netdev_nb_role_slug}" 292 | ) 293 | 294 | def ensure_device_platform(self, create_platform_if_missing=PLUGIN_SETTINGS["create_platform_if_missing"]): 295 | """Get platform object from NetBox filtered by platform_slug. 296 | 297 | Args: 298 | platform_slug (string): slug of a platform object present in NetBox, object will be created if not present 299 | and create_platform_if_missing is enabled 300 | 301 | Return: 302 | dcim.models.Platform object 303 | 304 | Raises: 305 | OnboardException 306 | 307 | Lookup is performed based on the object's slug field (not the name field) 308 | """ 309 | try: 310 | self.netdev_nb_platform_slug = ( 311 | self.netdev_nb_platform_slug 312 | or PLUGIN_SETTINGS["platform_map"].get(self.netdev_netmiko_device_type) 313 | or self.netdev_netmiko_device_type 314 | ) 315 | 316 | if not self.netdev_nb_platform_slug: 317 | raise OnboardException( 318 | reason="fail-config", message=f"ERROR device platform not found: {self.netdev_hostname}" 319 | ) 320 | 321 | self.nb_platform = Platform.objects.get(slug=self.netdev_nb_platform_slug) 322 | 323 | logger.info("PLATFORM: found in NetBox %s", self.netdev_nb_platform_slug) 324 | 325 | except Platform.DoesNotExist: 326 | if create_platform_if_missing: 327 | platform_to_napalm_netbox = { 328 | platform.slug: platform.napalm_driver 329 | for platform in Platform.objects.all() 330 | if platform.napalm_driver 331 | } 332 | 333 | # Update Constants if Napalm driver is defined for NetBox Platform 334 | netmiko_to_napalm = {**NETMIKO_TO_NAPALM_STATIC, **platform_to_napalm_netbox} 335 | 336 | self.nb_platform = Platform.objects.create( 337 | name=self.netdev_nb_platform_slug, 338 | slug=self.netdev_nb_platform_slug, 339 | napalm_driver=netmiko_to_napalm[self.netdev_netmiko_device_type], 340 | ) 341 | else: 342 | raise OnboardException( 343 | reason="fail-general", message=f"ERROR platform not found in NetBox: {self.netdev_nb_platform_slug}" 344 | ) 345 | 346 | def ensure_device_instance(self, default_status=PLUGIN_SETTINGS["default_device_status"]): 347 | """Ensure that the device instance exists in NetBox and is assigned the provided device role or DEFAULT_ROLE. 348 | 349 | Args: 350 | default_status (str) : status assigned to a new device by default. 351 | """ 352 | if self.onboarded_device: 353 | # Construct lookup arguments if onboarded device already exists in NetBox 354 | 355 | logger.info( 356 | "Found existing NetBox device (%s) for requested primary IP address (%s)", 357 | self.onboarded_device.name, 358 | self.netdev_mgmt_ip_address, 359 | ) 360 | lookup_args = { 361 | "pk": self.onboarded_device.pk, 362 | "defaults": dict( 363 | name=self.netdev_hostname, 364 | device_type=self.nb_device_type, 365 | device_role=self.nb_device_role, 366 | platform=self.nb_platform, 367 | site=self.nb_site, 368 | serial=self.netdev_serial_number, 369 | # status= field is not updated in case of already existing devices to prevent changes 370 | ), 371 | } 372 | else: 373 | # Construct lookup arguments if onboarded device does not exist in NetBox 374 | 375 | lookup_args = { 376 | "name": self.netdev_hostname, 377 | "defaults": dict( 378 | device_type=self.nb_device_type, 379 | device_role=self.nb_device_role, 380 | platform=self.nb_platform, 381 | site=self.nb_site, 382 | serial=self.netdev_serial_number, 383 | # status= defined only for new devices, no update for existing should occur 384 | status=default_status, 385 | ), 386 | } 387 | 388 | try: 389 | self.device, created = Device.objects.update_or_create(**lookup_args) 390 | 391 | if created: 392 | logger.info("CREATED device: %s", self.netdev_hostname) 393 | else: 394 | logger.info("GOT/UPDATED device: %s", self.netdev_hostname) 395 | 396 | except Device.MultipleObjectsReturned: 397 | raise OnboardException( 398 | reason="fail-general", 399 | message=f"ERROR multiple devices using same name in NetBox: {self.netdev_hostname}", 400 | ) 401 | 402 | def ensure_interface(self): 403 | """Ensures that the interface associated with the mgmt_ipaddr exists and is assigned to the device.""" 404 | if self.netdev_mgmt_ifname: 405 | self.nb_mgmt_ifname, _ = Interface.objects.get_or_create(name=self.netdev_mgmt_ifname, device=self.device) 406 | 407 | def ensure_primary_ip(self): 408 | """Ensure mgmt_ipaddr exists in IPAM, has the device interface, and is assigned as the primary IP address.""" 409 | # see if the primary IP address exists in IPAM 410 | if self.netdev_mgmt_ip_address and self.netdev_mgmt_pflen: 411 | self.nb_primary_ip, created = IPAddress.objects.get_or_create( 412 | address=f"{self.netdev_mgmt_ip_address}/{self.netdev_mgmt_pflen}" 413 | ) 414 | 415 | if created or not self.nb_primary_ip in self.nb_mgmt_ifname.ip_addresses.all(): 416 | logger.info("ASSIGN: IP address %s to %s", self.nb_primary_ip.address, self.nb_mgmt_ifname.name) 417 | self.nb_mgmt_ifname.ip_addresses.add(self.nb_primary_ip) 418 | self.nb_mgmt_ifname.save() 419 | 420 | # Ensure the primary IP is assigned to the device 421 | self.device.primary_ip4 = self.nb_primary_ip 422 | self.device.save() 423 | 424 | def ensure_device(self): 425 | """Ensure that the device represented by the DevNetKeeper exists in the NetBox system.""" 426 | self.ensure_onboarded_device() 427 | self.ensure_device_site() 428 | self.ensure_device_manufacturer() 429 | self.ensure_device_type() 430 | self.ensure_device_role() 431 | self.ensure_device_platform() 432 | self.ensure_device_instance() 433 | 434 | if PLUGIN_SETTINGS["create_management_interface_if_missing"]: 435 | self.ensure_interface() 436 | self.ensure_primary_ip() 437 | -------------------------------------------------------------------------------- /netbox_onboarding/netdev_keeper.py: -------------------------------------------------------------------------------- 1 | """NetDev Keeper. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | import importlib 16 | import logging 17 | import socket 18 | from dcim.models import Platform 19 | from django.conf import settings 20 | from napalm import get_network_driver 21 | from napalm.base.exceptions import ConnectionException, CommandErrorException 22 | from napalm.base.netmiko_helpers import netmiko_args 23 | from netmiko.ssh_autodetect import SSHDetect 24 | from netmiko.ssh_exception import NetMikoAuthenticationException 25 | from netmiko.ssh_exception import NetMikoTimeoutException 26 | from paramiko.ssh_exception import SSHException 27 | 28 | from netbox_onboarding.onboarding.onboarding import StandaloneOnboarding 29 | from .constants import NETMIKO_TO_NAPALM_STATIC 30 | from .exceptions import OnboardException 31 | 32 | logger = logging.getLogger("rq.worker") 33 | 34 | PLUGIN_SETTINGS = settings.PLUGINS_CONFIG["netbox_onboarding"] 35 | 36 | 37 | def get_mgmt_info( 38 | hostname, 39 | ip_ifs, 40 | default_mgmt_if=PLUGIN_SETTINGS["default_management_interface"], 41 | default_mgmt_pfxlen=PLUGIN_SETTINGS["default_management_prefix_length"], 42 | ): 43 | """Get the interface name and prefix length for the management interface. 44 | 45 | Locate the interface assigned with the hostname value and retain 46 | the interface name and IP prefix-length so that we can use it 47 | when creating the IPAM IP-Address instance. 48 | 49 | Note that in some cases (e.g., NAT) the hostname may differ than 50 | the interface addresses present on the device. We need to handle this. 51 | """ 52 | for if_name, if_data in ip_ifs.items(): 53 | for if_addr, if_addr_data in if_data["ipv4"].items(): 54 | if if_addr == hostname: 55 | return if_name, if_addr_data["prefix_length"] 56 | 57 | return default_mgmt_if, default_mgmt_pfxlen 58 | 59 | 60 | class NetdevKeeper: 61 | """Used to maintain information about the network device during the onboarding process.""" 62 | 63 | def __init__( # pylint: disable=R0913 64 | self, 65 | hostname, 66 | port=None, 67 | timeout=None, 68 | username=None, 69 | password=None, 70 | secret=None, 71 | napalm_driver=None, 72 | optional_args=None, 73 | ): 74 | """Initialize the network device keeper instance and ensure the required configuration parameters are provided. 75 | 76 | Args: 77 | hostname (str): IP Address or FQDN of an onboarded device 78 | port (int): Port used to connect to an onboarded device 79 | timeout (int): Connection timeout of an onboarded device 80 | username (str): Device username (if unspecified, NAPALM_USERNAME settings variable will be used) 81 | password (str): Device password (if unspecified, NAPALM_PASSWORD settings variable will be used) 82 | secret (str): Device secret password (if unspecified, NAPALM_ARGS["secret"] settings variable will be used) 83 | napalm_driver (str): Napalm driver name to use to onboard network device 84 | optional_args (dict): Optional arguments passed to NAPALM and Netmiko 85 | 86 | Raises: 87 | OnboardException('fail-config'): 88 | When any required config options are missing. 89 | """ 90 | # Attributes 91 | self.hostname = hostname 92 | self.port = port 93 | self.timeout = timeout 94 | self.username = username 95 | self.password = password 96 | self.secret = secret 97 | self.napalm_driver = napalm_driver 98 | 99 | # Netmiko and NAPALM expects optional_args to be a dictionary. 100 | if isinstance(optional_args, dict): 101 | self.optional_args = optional_args 102 | elif optional_args is None: 103 | self.optional_args = {} 104 | else: 105 | raise OnboardException(reason="fail-general", message="Optional arguments should be None or a dict") 106 | 107 | self.facts = None 108 | self.ip_ifs = None 109 | self.netmiko_device_type = None 110 | self.onboarding_class = StandaloneOnboarding 111 | self.driver_addon_result = None 112 | 113 | # Enable loading driver extensions 114 | self.load_driver_extension = True 115 | 116 | def check_reachability(self): 117 | """Ensure that the device at the mgmt-ipaddr provided is reachable. 118 | 119 | We do this check before attempting other "show" commands so that we know we've got a 120 | device that can be reached. 121 | 122 | Raises: 123 | OnboardException('fail-connect'): 124 | When device unreachable 125 | """ 126 | logger.info("CHECK: IP %s:%s", self.hostname, self.port) 127 | 128 | try: 129 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 130 | sock.settimeout(self.timeout) 131 | sock.connect((self.hostname, self.port)) 132 | 133 | except (socket.error, socket.timeout, ConnectionError): 134 | raise OnboardException( 135 | reason="fail-connect", message=f"ERROR device unreachable: {self.hostname}:{self.port}" 136 | ) 137 | 138 | def guess_netmiko_device_type(self): 139 | """Guess the device type of host, based on Netmiko.""" 140 | guessed_device_type = None 141 | 142 | netmiko_optional_args = netmiko_args(self.optional_args) 143 | 144 | remote_device = { 145 | "device_type": "autodetect", 146 | "host": self.hostname, 147 | "username": self.username, 148 | "password": self.password, 149 | **netmiko_optional_args, 150 | } 151 | 152 | if self.secret: 153 | remote_device["secret"] = self.secret 154 | 155 | if self.port: 156 | remote_device["port"] = self.port 157 | 158 | if self.timeout: 159 | remote_device["timeout"] = self.timeout 160 | 161 | try: 162 | logger.info("INFO guessing device type: %s", self.hostname) 163 | guesser = SSHDetect(**remote_device) 164 | guessed_device_type = guesser.autodetect() 165 | logger.info("INFO guessed device type: %s", guessed_device_type) 166 | 167 | except NetMikoAuthenticationException as err: 168 | logger.error("ERROR %s", err) 169 | raise OnboardException(reason="fail-login", message=f"ERROR: {str(err)}") 170 | 171 | except (NetMikoTimeoutException, SSHException) as err: 172 | logger.error("ERROR: %s", str(err)) 173 | raise OnboardException(reason="fail-connect", message=f"ERROR: {str(err)}") 174 | 175 | except Exception as err: 176 | logger.error("ERROR: %s", str(err)) 177 | raise OnboardException(reason="fail-general", message=f"ERROR: {str(err)}") 178 | 179 | else: 180 | if guessed_device_type is None: 181 | logger.error("ERROR: Could not detect device type with SSHDetect") 182 | raise OnboardException( 183 | reason="fail-general", message="ERROR: Could not detect device type with SSHDetect" 184 | ) 185 | 186 | return guessed_device_type 187 | 188 | def set_napalm_driver_name(self): 189 | """Sets napalm driver name.""" 190 | if not self.napalm_driver: 191 | netmiko_device_type = self.guess_netmiko_device_type() 192 | logger.info("Guessed Netmiko Device Type: %s", netmiko_device_type) 193 | 194 | self.netmiko_device_type = netmiko_device_type 195 | 196 | platform_to_napalm_netbox = { 197 | platform.slug: platform.napalm_driver for platform in Platform.objects.all() if platform.napalm_driver 198 | } 199 | 200 | # Update Constants if Napalm driver is defined for NetBox Platform 201 | netmiko_to_napalm = {**NETMIKO_TO_NAPALM_STATIC, **platform_to_napalm_netbox} 202 | 203 | self.napalm_driver = netmiko_to_napalm.get(netmiko_device_type) 204 | 205 | def check_napalm_driver_name(self): 206 | """Checks for napalm driver name.""" 207 | if not self.napalm_driver: 208 | raise OnboardException( 209 | reason="fail-general", 210 | message=f"Onboarding for Platform {self.netmiko_device_type} not " 211 | f"supported, as it has no specified NAPALM driver", 212 | ) 213 | 214 | def get_onboarding_facts(self): 215 | """Gather information from the network device that is needed to onboard the device into the NetBox system. 216 | 217 | Raises: 218 | OnboardException('fail-login'): 219 | When unable to login to device 220 | 221 | OnboardException('fail-execute'): 222 | When unable to run commands to collect device information 223 | 224 | OnboardException('fail-general'): 225 | Any other unexpected device comms failure. 226 | """ 227 | self.check_reachability() 228 | 229 | logger.info("COLLECT: device information %s", self.hostname) 230 | 231 | try: 232 | # Get Napalm Driver with Netmiko if needed 233 | self.set_napalm_driver_name() 234 | 235 | # Raise if no Napalm Driver not selected 236 | self.check_napalm_driver_name() 237 | 238 | driver = get_network_driver(self.napalm_driver) 239 | 240 | # Create NAPALM optional arguments 241 | napalm_optional_args = self.optional_args.copy() 242 | 243 | if self.port: 244 | napalm_optional_args["port"] = self.port 245 | 246 | if self.secret: 247 | napalm_optional_args["secret"] = self.secret 248 | 249 | napalm_device = driver( 250 | hostname=self.hostname, 251 | username=self.username, 252 | password=self.password, 253 | timeout=self.timeout, 254 | optional_args=napalm_optional_args, 255 | ) 256 | 257 | napalm_device.open() 258 | 259 | logger.info("COLLECT: device facts") 260 | self.facts = napalm_device.get_facts() 261 | 262 | logger.info("COLLECT: device interface IPs") 263 | self.ip_ifs = napalm_device.get_interfaces_ip() 264 | 265 | module_name = PLUGIN_SETTINGS["onboarding_extensions_map"].get(self.napalm_driver) 266 | 267 | if module_name and self.load_driver_extension: 268 | try: 269 | module = importlib.import_module(module_name) 270 | driver_addon_class = module.OnboardingDriverExtensions(napalm_device=napalm_device) 271 | self.onboarding_class = driver_addon_class.onboarding_class 272 | self.driver_addon_result = driver_addon_class.ext_result 273 | except ModuleNotFoundError as exc: 274 | raise OnboardException( 275 | reason="fail-general", 276 | message=f"ERROR: ModuleNotFoundError: Onboarding extension for napalm driver {self.napalm_driver} configured but can not be imported per configuration", 277 | ) 278 | except ImportError as exc: 279 | raise OnboardException(reason="fail-general", message="ERROR: ImportError: %s" % exc.args[0]) 280 | elif module_name and not self.load_driver_extension: 281 | logger.info("INFO: Skipping execution of driver extension") 282 | else: 283 | logger.info( 284 | "INFO: No onboarding extension defined for napalm driver %s, using default napalm driver", 285 | self.napalm_driver, 286 | ) 287 | 288 | except ConnectionException as exc: 289 | raise OnboardException(reason="fail-login", message=exc.args[0]) 290 | 291 | except CommandErrorException as exc: 292 | raise OnboardException(reason="fail-execute", message=exc.args[0]) 293 | 294 | except Exception as exc: 295 | raise OnboardException(reason="fail-general", message=str(exc)) 296 | 297 | def get_netdev_dict(self): 298 | """Construct network device dict.""" 299 | netdev_dict = { 300 | "netdev_hostname": self.facts["hostname"], 301 | "netdev_vendor": self.facts["vendor"].title(), 302 | "netdev_model": self.facts["model"].lower(), 303 | "netdev_serial_number": self.facts["serial_number"], 304 | "netdev_mgmt_ifname": get_mgmt_info(hostname=self.hostname, ip_ifs=self.ip_ifs)[0], 305 | "netdev_mgmt_pflen": get_mgmt_info(hostname=self.hostname, ip_ifs=self.ip_ifs)[1], 306 | "netdev_netmiko_device_type": self.netmiko_device_type, 307 | "onboarding_class": self.onboarding_class, 308 | "driver_addon_result": self.driver_addon_result, 309 | } 310 | 311 | return netdev_dict 312 | -------------------------------------------------------------------------------- /netbox_onboarding/onboard.py: -------------------------------------------------------------------------------- 1 | """Onboard. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | from django.conf import settings 16 | 17 | from .netdev_keeper import NetdevKeeper 18 | 19 | PLUGIN_SETTINGS = settings.PLUGINS_CONFIG["netbox_onboarding"] 20 | 21 | 22 | class OnboardingTaskManager: 23 | """Onboarding Task Manager.""" 24 | 25 | def __init__(self, ot): 26 | """Inits class.""" 27 | self.ot = ot 28 | 29 | @property 30 | def napalm_driver(self): 31 | """Return napalm driver name.""" 32 | if self.ot.platform and self.ot.platform.napalm_driver: 33 | return self.ot.platform.napalm_driver 34 | 35 | return None 36 | 37 | @property 38 | def optional_args(self): 39 | """Return platform optional args.""" 40 | if self.ot.platform and self.ot.platform.napalm_args: 41 | return self.ot.platform.napalm_args 42 | 43 | return {} 44 | 45 | @property 46 | def ip_address(self): 47 | """Return ot's ip address.""" 48 | return self.ot.ip_address 49 | 50 | @property 51 | def port(self): 52 | """Return ot's port.""" 53 | return self.ot.port 54 | 55 | @property 56 | def timeout(self): 57 | """Return ot's timeout.""" 58 | return self.ot.timeout 59 | 60 | @property 61 | def site(self): 62 | """Return ot's site.""" 63 | return self.ot.site 64 | 65 | @property 66 | def device_type(self): 67 | """Return ot's device type.""" 68 | return self.ot.device_type 69 | 70 | @property 71 | def role(self): 72 | """Return it's device role.""" 73 | return self.ot.role 74 | 75 | @property 76 | def platform(self): 77 | """Return ot's device platform.""" 78 | return self.ot.platform 79 | 80 | 81 | class OnboardingManager: 82 | """Onboarding Manager.""" 83 | 84 | def __init__(self, ot, username, password, secret): 85 | """Inits class.""" 86 | # Create instance of Onboarding Task Manager class: 87 | otm = OnboardingTaskManager(ot) 88 | 89 | self.username = username or settings.NAPALM_USERNAME 90 | self.password = password or settings.NAPALM_PASSWORD 91 | self.secret = secret or otm.optional_args.get("secret", None) or settings.NAPALM_ARGS.get("secret", None) 92 | 93 | netdev = NetdevKeeper( 94 | hostname=otm.ip_address, 95 | port=otm.port, 96 | timeout=otm.timeout, 97 | username=self.username, 98 | password=self.password, 99 | secret=self.secret, 100 | napalm_driver=otm.napalm_driver, 101 | optional_args=otm.optional_args or settings.NAPALM_ARGS, 102 | ) 103 | 104 | netdev.get_onboarding_facts() 105 | netdev_dict = netdev.get_netdev_dict() 106 | 107 | onboarding_kwargs = { 108 | # Kwargs extracted from OnboardingTask: 109 | "netdev_mgmt_ip_address": otm.ip_address, 110 | "netdev_nb_site_slug": otm.site.slug if otm.site else None, 111 | "netdev_nb_device_type_slug": otm.device_type, 112 | "netdev_nb_role_slug": otm.role.slug if otm.role else PLUGIN_SETTINGS["default_device_role"], 113 | "netdev_nb_role_color": PLUGIN_SETTINGS["default_device_role_color"], 114 | "netdev_nb_platform_slug": otm.platform.slug if otm.platform else None, 115 | # Kwargs discovered on the Onboarded Device: 116 | "netdev_hostname": netdev_dict["netdev_hostname"], 117 | "netdev_vendor": netdev_dict["netdev_vendor"], 118 | "netdev_model": netdev_dict["netdev_model"], 119 | "netdev_serial_number": netdev_dict["netdev_serial_number"], 120 | "netdev_mgmt_ifname": netdev_dict["netdev_mgmt_ifname"], 121 | "netdev_mgmt_pflen": netdev_dict["netdev_mgmt_pflen"], 122 | "netdev_netmiko_device_type": netdev_dict["netdev_netmiko_device_type"], 123 | "onboarding_class": netdev_dict["onboarding_class"], 124 | "driver_addon_result": netdev_dict["driver_addon_result"], 125 | } 126 | 127 | onboarding_cls = netdev_dict["onboarding_class"]() 128 | onboarding_cls.credentials = {"username": self.username, "password": self.password, "secret": self.secret} 129 | onboarding_cls.run(onboarding_kwargs=onboarding_kwargs) 130 | 131 | self.created_device = onboarding_cls.created_device 132 | -------------------------------------------------------------------------------- /netbox_onboarding/onboarding/__init__.py: -------------------------------------------------------------------------------- 1 | """Onboarding. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | -------------------------------------------------------------------------------- /netbox_onboarding/onboarding/onboarding.py: -------------------------------------------------------------------------------- 1 | """Onboarding module. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | from netbox_onboarding.netbox_keeper import NetboxKeeper 16 | 17 | 18 | class Onboarding: 19 | """Generic onboarding class.""" 20 | 21 | def __init__(self): 22 | """Init the class.""" 23 | self.created_device = None 24 | self.credentials = None 25 | 26 | def run(self, onboarding_kwargs): 27 | """Implement run method.""" 28 | raise NotImplementedError 29 | 30 | 31 | class StandaloneOnboarding(Onboarding): 32 | """Standalone onboarding class.""" 33 | 34 | def run(self, onboarding_kwargs): 35 | """Ensure device is created with NetBox Keeper.""" 36 | nb_k = NetboxKeeper(**onboarding_kwargs) 37 | nb_k.ensure_device() 38 | 39 | self.created_device = nb_k.device 40 | -------------------------------------------------------------------------------- /netbox_onboarding/onboarding_extensions/__init__.py: -------------------------------------------------------------------------------- 1 | """Onboarding Extensions. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | -------------------------------------------------------------------------------- /netbox_onboarding/onboarding_extensions/ios.py: -------------------------------------------------------------------------------- 1 | """Onboarding Extension for IOS. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | from netbox_onboarding.onboarding.onboarding import StandaloneOnboarding 16 | 17 | 18 | class OnboardingDriverExtensions: 19 | """Onboarding Driver's Extensions.""" 20 | 21 | def __init__(self, napalm_device): 22 | """Initialize class.""" 23 | self.napalm_device = napalm_device 24 | 25 | @property 26 | def onboarding_class(self): 27 | """Return onboarding class for IOS driver. 28 | 29 | Currently supported is Standalone Onboarding Process. 30 | 31 | Result of this method is used by the OnboardingManager to 32 | initiate the instance of the onboarding class. 33 | """ 34 | return StandaloneOnboarding 35 | 36 | @property 37 | def ext_result(self): 38 | """This method is used to store any object as a return value. 39 | 40 | Result of this method is passed to the onboarding class as 41 | driver_addon_result argument. 42 | 43 | :return: Any() 44 | """ 45 | return None 46 | -------------------------------------------------------------------------------- /netbox_onboarding/release.py: -------------------------------------------------------------------------------- 1 | """Release variables of the NetBox. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | from packaging import version 16 | from django.conf import settings 17 | 18 | NETBOX_RELEASE_CURRENT = version.parse(settings.VERSION) 19 | NETBOX_RELEASE_28 = version.parse("2.8") 20 | NETBOX_RELEASE_29 = version.parse("2.9") 21 | NETBOX_RELEASE_210 = version.parse("2.10") 22 | NETBOX_RELEASE_211 = version.parse("2.11") 23 | -------------------------------------------------------------------------------- /netbox_onboarding/tables.py: -------------------------------------------------------------------------------- 1 | """Tables for device onboarding tasks. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | import django_tables2 as tables 15 | from utilities.tables import BaseTable, ToggleColumn 16 | from .models import OnboardingTask 17 | 18 | 19 | class OnboardingTaskTable(BaseTable): 20 | """Table for displaying OnboardingTask instances.""" 21 | 22 | pk = ToggleColumn() 23 | id = tables.LinkColumn() 24 | site = tables.LinkColumn() 25 | platform = tables.LinkColumn() 26 | created_device = tables.LinkColumn() 27 | 28 | class Meta(BaseTable.Meta): # noqa: D106 "Missing docstring in public nested class" 29 | model = OnboardingTask 30 | fields = ( 31 | "pk", 32 | "id", 33 | "created", 34 | "ip_address", 35 | "site", 36 | "platform", 37 | "created_device", 38 | "status", 39 | "failed_reason", 40 | "message", 41 | ) 42 | 43 | 44 | class OnboardingTaskFeedBulkTable(BaseTable): 45 | """TODO document me.""" 46 | 47 | site = tables.LinkColumn() 48 | 49 | class Meta(BaseTable.Meta): # noqa: D106 "Missing docstring in public nested class" 50 | model = OnboardingTask 51 | fields = ( 52 | "id", 53 | "created", 54 | "site", 55 | "platform", 56 | "ip_address", 57 | "port", 58 | "timeout", 59 | ) 60 | -------------------------------------------------------------------------------- /netbox_onboarding/template_content.py: -------------------------------------------------------------------------------- 1 | """Onboarding template content. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | from extras.plugins import PluginTemplateExtension 16 | from .models import OnboardingDevice 17 | 18 | 19 | class DeviceContent(PluginTemplateExtension): # pylint: disable=abstract-method 20 | """Table to show onboarding details on Device objects.""" 21 | 22 | model = "dcim.device" 23 | 24 | def right_page(self): 25 | """Show table on right side of view.""" 26 | onboarding = OnboardingDevice.objects.filter(device=self.context["object"]).first() 27 | 28 | if not onboarding or not onboarding.enabled: 29 | return "" 30 | 31 | status = onboarding.status 32 | last_check_attempt_date = onboarding.last_check_attempt_date 33 | last_check_successful_date = onboarding.last_check_successful_date 34 | last_ot = onboarding.last_ot 35 | 36 | return self.render( 37 | "netbox_onboarding/device_onboarding_table.html", 38 | extra_context={ 39 | "status": status, 40 | "last_check_attempt_date": last_check_attempt_date, 41 | "last_check_successful_date": last_check_successful_date, 42 | "last_ot": last_ot, 43 | }, 44 | ) 45 | 46 | 47 | template_extensions = [DeviceContent] 48 | -------------------------------------------------------------------------------- /netbox_onboarding/templates/netbox_onboarding/device_onboarding_table.html: -------------------------------------------------------------------------------- 1 | {% block content %} 2 |
3 |
4 | Device Onboarding 5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 18 | 21 | 24 | 27 | 28 | 29 |
DateStatusDate of last successLatest Task
16 | {{ last_check_attempt_date }} 17 | 19 | {{ status }} 20 | 22 | {{ last_check_successful_date }} 23 | 25 | {{ last_ot.pk }} 26 |
30 |
31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /netbox_onboarding/templates/netbox_onboarding/onboarding_task_edit.html: -------------------------------------------------------------------------------- 1 | {% if "2.8." in settings.VERSION or "2.9." in settings.VERSION %} 2 | {% include 'utilities/obj_edit.html' %} 3 | {% else %} 4 | {% include 'generic/object_edit.html' %} 5 | {% endif %} 6 | {% load form_helpers %} 7 | -------------------------------------------------------------------------------- /netbox_onboarding/templates/netbox_onboarding/onboarding_tasks_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load buttons %} 3 | 4 | {% block content %} 5 |
6 | {% if permissions.add %} 7 | {% add_button 'plugins:netbox_onboarding:onboardingtask_add' %} 8 | {% import_button 'plugins:netbox_onboarding:onboardingtask_import' %} 9 | {% endif %} 10 |
11 |

{% block title %}Onboarding Tasks{% endblock %}

12 |
13 |
14 | {% include 'utilities/obj_table.html' with bulk_delete_url="plugins:netbox_onboarding:onboardingtask_bulk_delete" %} 15 |
16 |
17 | {% include 'inc/search_panel.html' %} 18 |
19 |
20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /netbox_onboarding/templates/netbox_onboarding/onboardingtask.html: -------------------------------------------------------------------------------- 1 | {% if "2.8." in settings.VERSION or "2.9." in settings.VERSION %} 2 | {% include 'netbox_onboarding/onboardingtask_lt210.html' %} 3 | {% else %} 4 | {% include 'netbox_onboarding/onboardingtask_ge210.html' %} 5 | {% endif %} 6 | -------------------------------------------------------------------------------- /netbox_onboarding/templates/netbox_onboarding/onboardingtask_ge210.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load helpers %} 3 | {% load static %} 4 | 5 | {% block header %} 6 |
7 |
8 | 12 |
13 |
14 | 15 |

{% block title %}Device: {{ object.ip_address }}{% endblock %}

16 | 17 | 27 | {% endblock %} 28 | 29 | {% block content %} 30 |
31 |
32 |
33 |
34 | Onboarding Task 35 |
36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 |
Created Device{{ object.created_device|placeholder }}
IP Address{{ object.ip_address|placeholder }}
Port{{ object.port|placeholder }}
Timeout{{ object.timeout|placeholder }}
Site{{ object.site|placeholder }}
Role{{ object.role|placeholder }}
Device Type{{ object.device_type|placeholder }}
Platform{{ object.platform|placeholder }}
Status{{ object.status|placeholder }}
Failed Reason{{ object.failed_reason|placeholder }}
Message{{ object.message|placeholder }}
Created{{ object.created|placeholder }}
86 |
87 |
88 |
89 | {% endblock %} 90 | 91 | {% block javascript %} 92 | 93 | {% endblock %} 94 | -------------------------------------------------------------------------------- /netbox_onboarding/templates/netbox_onboarding/onboardingtask_lt210.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load helpers %} 3 | {% load static %} 4 | 5 | {% block header %} 6 |
7 |
8 | 12 |
13 |
14 | 15 |

{% block title %}Device: {{ onboardingtask.ip_address }}{% endblock %}

16 | 17 | 27 | {% endblock %} 28 | 29 | {% block content %} 30 |
31 |
32 |
33 |
34 | Onboarding Task 35 |
36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 |
Created Device{{ onboardingtask.created_device|placeholder }}
IP Address{{ onboardingtask.ip_address|placeholder }}
Port{{ onboardingtask.port|placeholder }}
Timeout{{ onboardingtask.timeout|placeholder }}
Site{{ onboardingtask.site|placeholder }}
Role{{ onboardingtask.role|placeholder }}
Device Type{{ onboardingtask.device_type|placeholder }}
Platform{{ onboardingtask.platform|placeholder }}
Status{{ onboardingtask.status|placeholder }}
Failed Reason{{ onboardingtask.failed_reason|placeholder }}
Message{{ onboardingtask.message|placeholder }}
Created{{ onboardingtask.created|placeholder }}
86 |
87 |
88 |
89 | {% endblock %} 90 | 91 | {% block javascript %} 92 | 93 | {% endblock %} 94 | -------------------------------------------------------------------------------- /netbox_onboarding/tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit tests for netbox_onboarding plugin.""" 2 | -------------------------------------------------------------------------------- /netbox_onboarding/tests/test_api.py: -------------------------------------------------------------------------------- 1 | """Unit tests for netbox_onboarding REST API. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | from django.contrib.auth.models import User # pylint: disable=imported-auth-user 15 | from django.test import TestCase 16 | from django.urls import reverse 17 | from rest_framework import status 18 | from rest_framework.test import APIClient 19 | 20 | from users.models import Token 21 | 22 | from dcim.models import Site 23 | 24 | 25 | from netbox_onboarding.models import OnboardingTask 26 | 27 | 28 | class OnboardingTaskTestCase(TestCase): 29 | """Test the OnboardingTask API.""" 30 | 31 | def setUp(self): 32 | """Create a superuser and token for API calls.""" 33 | self.user = User.objects.create(username="testuser", is_superuser=True) 34 | self.token = Token.objects.create(user=self.user) 35 | self.client = APIClient() 36 | self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.token.key}") 37 | 38 | self.base_url_lookup = "plugins-api:netbox_onboarding-api:onboardingtask" 39 | 40 | self.site1 = Site.objects.create(name="USWEST", slug="uswest") 41 | 42 | self.onboarding_task1 = OnboardingTask.objects.create(ip_address="10.10.10.10", site=self.site1) 43 | self.onboarding_task2 = OnboardingTask.objects.create(ip_address="192.168.1.1", site=self.site1) 44 | 45 | def test_list_onboarding_tasks(self): 46 | """Verify that OnboardingTasks can be listed.""" 47 | url = reverse(f"{self.base_url_lookup}-list") 48 | 49 | response = self.client.get(url) 50 | self.assertEqual(response.status_code, status.HTTP_200_OK) 51 | self.assertEqual(response.data["count"], 2) 52 | 53 | def test_get_onboarding_task(self): 54 | """Verify that an Onboardingtask can be retrieved.""" 55 | url = reverse(f"{self.base_url_lookup}-detail", kwargs={"pk": self.onboarding_task1.pk}) 56 | 57 | response = self.client.get(url) 58 | self.assertEqual(response.status_code, status.HTTP_200_OK) 59 | self.assertEqual(response.data["ip_address"], self.onboarding_task1.ip_address) 60 | self.assertEqual(response.data["site"], self.onboarding_task1.site.slug) 61 | 62 | def test_create_task_missing_mandatory_parameters(self): 63 | """Verify that the only mandatory POST parameters are ip_address and site.""" 64 | url = reverse(f"{self.base_url_lookup}-list") 65 | 66 | response = self.client.post(url, format="json") 67 | self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) 68 | # The response tells us which fields are missing from the request 69 | self.assertIn("ip_address", response.data) 70 | self.assertIn("site", response.data) 71 | self.assertEqual(len(response.data), 2, "Only two parameters should be mandatory") 72 | 73 | def test_create_task(self): 74 | """Verify that an OnboardingTask can be created.""" 75 | url = reverse(f"{self.base_url_lookup}-list") 76 | data = {"ip_address": "10.10.10.20", "site": self.site1.slug} 77 | 78 | response = self.client.post(url, data, format="json") 79 | self.assertEqual(response.status_code, status.HTTP_201_CREATED) 80 | for key, value in data.items(): 81 | self.assertEqual(response.data[key], value) 82 | self.assertEqual(response.data["port"], 22) # default value 83 | self.assertEqual(response.data["timeout"], 30) # default value 84 | 85 | onboarding_task = OnboardingTask.objects.get(pk=response.data["id"]) 86 | self.assertEqual(onboarding_task.ip_address, data["ip_address"]) 87 | self.assertEqual(onboarding_task.site, self.site1) 88 | 89 | def test_update_task_forbidden(self): 90 | """Verify that an OnboardingTask cannot be updated via this API.""" 91 | url = reverse(f"{self.base_url_lookup}-detail", kwargs={"pk": self.onboarding_task1.pk}) 92 | 93 | response = self.client.patch(url, {"ip_address": "10.10.10.20"}, format="json") 94 | self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) 95 | self.assertEqual(self.onboarding_task1.ip_address, "10.10.10.10") 96 | 97 | response = self.client.put(url, {"ip_address": "10.10.10.20"}, format="json") 98 | self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) 99 | self.assertEqual(self.onboarding_task1.ip_address, "10.10.10.10") 100 | 101 | def test_delete_task(self): 102 | """Verify that an OnboardingTask can be deleted.""" 103 | url = reverse(f"{self.base_url_lookup}-detail", kwargs={"pk": self.onboarding_task1.pk}) 104 | 105 | response = self.client.delete(url) 106 | self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) 107 | with self.assertRaises(OnboardingTask.DoesNotExist): 108 | OnboardingTask.objects.get(pk=self.onboarding_task1.pk) 109 | -------------------------------------------------------------------------------- /netbox_onboarding/tests/test_models.py: -------------------------------------------------------------------------------- 1 | """Unit tests for netbox_onboarding OnboardingDevice model. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | from django.test import TestCase 15 | 16 | from dcim.models import Site, DeviceRole, DeviceType, Manufacturer, Device, Interface 17 | from ipam.models import IPAddress 18 | 19 | from netbox_onboarding.models import OnboardingTask 20 | from netbox_onboarding.models import OnboardingDevice 21 | from netbox_onboarding.choices import OnboardingStatusChoices 22 | 23 | 24 | class OnboardingDeviceModelTestCase(TestCase): 25 | """Test the Onboarding models.""" 26 | 27 | def setUp(self): 28 | """Setup objects for Onboarding Model tests.""" 29 | self.site = Site.objects.create(name="USWEST", slug="uswest") 30 | manufacturer = Manufacturer.objects.create(name="Juniper", slug="juniper") 31 | device_role = DeviceRole.objects.create(name="Firewall", slug="firewall") 32 | device_type = DeviceType.objects.create(slug="srx3600", model="SRX3600", manufacturer=manufacturer) 33 | 34 | self.device = Device.objects.create( 35 | device_type=device_type, name="device1", device_role=device_role, site=self.site, 36 | ) 37 | 38 | intf = Interface.objects.create(name="test_intf", device=self.device) 39 | 40 | primary_ip = IPAddress.objects.create(address="10.10.10.10/32") 41 | intf.ip_addresses.add(primary_ip) 42 | 43 | self.device.primary_ip4 = primary_ip 44 | self.device.save() 45 | 46 | self.succeeded_task1 = OnboardingTask.objects.create( 47 | ip_address="10.10.10.10", 48 | site=self.site, 49 | status=OnboardingStatusChoices.STATUS_SUCCEEDED, 50 | created_device=self.device, 51 | ) 52 | 53 | self.succeeded_task2 = OnboardingTask.objects.create( 54 | ip_address="10.10.10.10", 55 | site=self.site, 56 | status=OnboardingStatusChoices.STATUS_SUCCEEDED, 57 | created_device=self.device, 58 | ) 59 | 60 | self.failed_task1 = OnboardingTask.objects.create( 61 | ip_address="10.10.10.10", 62 | site=self.site, 63 | status=OnboardingStatusChoices.STATUS_FAILED, 64 | created_device=self.device, 65 | ) 66 | 67 | self.failed_task2 = OnboardingTask.objects.create( 68 | ip_address="10.10.10.10", 69 | site=self.site, 70 | status=OnboardingStatusChoices.STATUS_FAILED, 71 | created_device=self.device, 72 | ) 73 | 74 | def test_onboardingdevice_autocreated(self): 75 | """Verify that OnboardingDevice is auto-created.""" 76 | onboarding_device = OnboardingDevice.objects.get(device=self.device) 77 | self.assertEqual(self.device, onboarding_device.device) 78 | 79 | def test_last_check_attempt_date(self): 80 | """Verify OnboardingDevice last attempt.""" 81 | onboarding_device = OnboardingDevice.objects.get(device=self.device) 82 | self.assertEqual(onboarding_device.last_check_attempt_date, self.failed_task2.created) 83 | 84 | def test_last_check_successful_date(self): 85 | """Verify OnboardingDevice last success.""" 86 | onboarding_device = OnboardingDevice.objects.get(device=self.device) 87 | self.assertEqual(onboarding_device.last_check_successful_date, self.succeeded_task2.created) 88 | 89 | def test_status(self): 90 | """Verify OnboardingDevice status.""" 91 | onboarding_device = OnboardingDevice.objects.get(device=self.device) 92 | self.assertEqual(onboarding_device.status, self.failed_task2.status) 93 | 94 | def test_last_ot(self): 95 | """Verify OnboardingDevice last ot.""" 96 | onboarding_device = OnboardingDevice.objects.get(device=self.device) 97 | self.assertEqual(onboarding_device.last_ot, self.failed_task2) 98 | -------------------------------------------------------------------------------- /netbox_onboarding/tests/test_netbox_keeper.py: -------------------------------------------------------------------------------- 1 | """Unit tests for netbox_onboarding.onboard module and its classes. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | from django.conf import settings 15 | from django.test import TestCase 16 | from django.utils.text import slugify 17 | from dcim.models import Site, Manufacturer, DeviceType, DeviceRole, Device, Interface, Platform 18 | from ipam.models import IPAddress 19 | 20 | # from netbox_onboarding.netbox_keeper import NetdevKeeper 21 | from netbox_onboarding.exceptions import OnboardException 22 | from netbox_onboarding.netbox_keeper import NetboxKeeper 23 | 24 | PLUGIN_SETTINGS = settings.PLUGINS_CONFIG["netbox_onboarding"] 25 | 26 | 27 | class NetboxKeeperTestCase(TestCase): 28 | """Test the NetboxKeeper Class.""" 29 | 30 | def setUp(self): 31 | """Create a superuser and token for API calls.""" 32 | self.site1 = Site.objects.create(name="USWEST", slug="uswest") 33 | 34 | def test_ensure_device_manufacturer_strict_missing(self): 35 | """Verify ensure_device_manufacturer function when Manufacturer object is not present.""" 36 | PLUGIN_SETTINGS["object_match_strategy"] = "strict" 37 | onboarding_kwargs = { 38 | "netdev_hostname": "device1", 39 | "netdev_nb_role_slug": PLUGIN_SETTINGS["default_device_role"], 40 | "netdev_vendor": "Cisco", 41 | "netdev_model": "CSR1000v", 42 | "netdev_nb_site_slug": self.site1.slug, 43 | } 44 | 45 | nbk = NetboxKeeper(**onboarding_kwargs) 46 | 47 | with self.assertRaises(OnboardException) as exc_info: 48 | nbk.ensure_device_manufacturer(create_manufacturer=False) 49 | self.assertEqual(exc_info.exception.message, "ERROR manufacturer not found: Cisco") 50 | self.assertEqual(exc_info.exception.reason, "fail-config") 51 | 52 | nbk.ensure_device_manufacturer(create_manufacturer=True) 53 | self.assertIsInstance(nbk.nb_manufacturer, Manufacturer) 54 | self.assertEqual(nbk.nb_manufacturer.slug, slugify(onboarding_kwargs["netdev_vendor"])) 55 | 56 | def test_ensure_device_manufacturer_loose_missing(self): 57 | """Verify ensure_device_manufacturer function when Manufacturer object is not present.""" 58 | PLUGIN_SETTINGS["object_match_strategy"] = "loose" 59 | onboarding_kwargs = { 60 | "netdev_hostname": "device1", 61 | "netdev_nb_role_slug": PLUGIN_SETTINGS["default_device_role"], 62 | "netdev_vendor": "Cisco", 63 | "netdev_model": "CSR1000v", 64 | "netdev_nb_site_slug": self.site1.slug, 65 | } 66 | 67 | nbk = NetboxKeeper(**onboarding_kwargs) 68 | 69 | with self.assertRaises(OnboardException) as exc_info: 70 | nbk.ensure_device_manufacturer(create_manufacturer=False) 71 | self.assertEqual(exc_info.exception.message, "ERROR manufacturer not found: Cisco") 72 | self.assertEqual(exc_info.exception.reason, "fail-config") 73 | 74 | nbk.ensure_device_manufacturer(create_manufacturer=True) 75 | self.assertIsInstance(nbk.nb_manufacturer, Manufacturer) 76 | self.assertEqual(nbk.nb_manufacturer.slug, slugify(onboarding_kwargs["netdev_vendor"])) 77 | 78 | def test_ensure_device_type_strict_missing(self): 79 | """Verify ensure_device_type function when DeviceType object is not present.""" 80 | PLUGIN_SETTINGS["object_match_strategy"] = "strict" 81 | onboarding_kwargs = { 82 | "netdev_hostname": "device1", 83 | "netdev_nb_role_slug": PLUGIN_SETTINGS["default_device_role"], 84 | "netdev_vendor": "Cisco", 85 | "netdev_model": "CSR1000v", 86 | "netdev_nb_site_slug": self.site1.slug, 87 | } 88 | 89 | nbk = NetboxKeeper(**onboarding_kwargs) 90 | nbk.nb_manufacturer = Manufacturer.objects.create(name="Cisco", slug="cisco") 91 | 92 | with self.assertRaises(OnboardException) as exc_info: 93 | nbk.ensure_device_type(create_device_type=False) 94 | self.assertEqual(exc_info.exception.message, "ERROR device type not found: CSR1000v") 95 | self.assertEqual(exc_info.exception.reason, "fail-config") 96 | 97 | nbk.ensure_device_type(create_device_type=True) 98 | self.assertIsInstance(nbk.nb_device_type, DeviceType) 99 | self.assertEqual(nbk.nb_device_type.slug, slugify(onboarding_kwargs["netdev_model"])) 100 | 101 | def test_ensure_device_type_loose_missing(self): 102 | """Verify ensure_device_type function when DeviceType object is not present.""" 103 | PLUGIN_SETTINGS["object_match_strategy"] = "loose" 104 | onboarding_kwargs = { 105 | "netdev_hostname": "device1", 106 | "netdev_nb_role_slug": PLUGIN_SETTINGS["default_device_role"], 107 | "netdev_vendor": "Cisco", 108 | "netdev_model": "CSR1000v", 109 | "netdev_nb_site_slug": self.site1.slug, 110 | } 111 | 112 | nbk = NetboxKeeper(**onboarding_kwargs) 113 | nbk.nb_manufacturer = Manufacturer.objects.create(name="Cisco", slug="cisco") 114 | 115 | with self.assertRaises(OnboardException) as exc_info: 116 | nbk.ensure_device_type(create_device_type=False) 117 | self.assertEqual(exc_info.exception.message, "ERROR device type not found: CSR1000v") 118 | self.assertEqual(exc_info.exception.reason, "fail-config") 119 | 120 | nbk.ensure_device_type(create_device_type=True) 121 | self.assertIsInstance(nbk.nb_device_type, DeviceType) 122 | self.assertEqual(nbk.nb_device_type.slug, slugify(onboarding_kwargs["netdev_model"])) 123 | 124 | def test_ensure_device_type_strict_present(self): 125 | """Verify ensure_device_type function when DeviceType object is already present.""" 126 | PLUGIN_SETTINGS["object_match_strategy"] = "strict" 127 | manufacturer = Manufacturer.objects.create(name="Juniper", slug="juniper") 128 | 129 | device_type = DeviceType.objects.create(slug="srx3600", model="SRX3600", manufacturer=manufacturer) 130 | 131 | onboarding_kwargs = { 132 | "netdev_hostname": "device2", 133 | "netdev_nb_role_slug": PLUGIN_SETTINGS["default_device_role"], 134 | "netdev_vendor": "Juniper", 135 | "netdev_nb_device_type_slug": device_type.slug, 136 | "netdev_nb_site_slug": self.site1.slug, 137 | } 138 | 139 | nbk = NetboxKeeper(**onboarding_kwargs) 140 | nbk.nb_manufacturer = manufacturer 141 | 142 | nbk.ensure_device_type(create_device_type=False) 143 | self.assertEqual(nbk.nb_device_type, device_type) 144 | 145 | def test_ensure_device_type_loose_present(self): 146 | """Verify ensure_device_type function when DeviceType object is already present.""" 147 | PLUGIN_SETTINGS["object_match_strategy"] = "loose" 148 | manufacturer = Manufacturer.objects.create(name="Juniper", slug="juniper") 149 | 150 | device_type = DeviceType.objects.create(slug="srx3600", model="SRX3600", manufacturer=manufacturer) 151 | 152 | onboarding_kwargs = { 153 | "netdev_hostname": "device2", 154 | "netdev_nb_role_slug": PLUGIN_SETTINGS["default_device_role"], 155 | "netdev_vendor": "Juniper", 156 | "netdev_nb_device_type_slug": device_type.slug, 157 | "netdev_nb_site_slug": self.site1.slug, 158 | } 159 | 160 | nbk = NetboxKeeper(**onboarding_kwargs) 161 | nbk.nb_manufacturer = manufacturer 162 | 163 | nbk.ensure_device_type(create_device_type=False) 164 | self.assertEqual(nbk.nb_device_type, device_type) 165 | 166 | def test_ensure_device_role_not_exist(self): 167 | """Verify ensure_device_role function when DeviceRole does not already exist.""" 168 | test_role_name = "mytestrole" 169 | 170 | onboarding_kwargs = { 171 | "netdev_hostname": "device1", 172 | "netdev_nb_role_slug": test_role_name, 173 | "netdev_nb_role_color": PLUGIN_SETTINGS["default_device_role_color"], 174 | "netdev_vendor": "Cisco", 175 | "netdev_nb_site_slug": self.site1.slug, 176 | } 177 | 178 | nbk = NetboxKeeper(**onboarding_kwargs) 179 | 180 | with self.assertRaises(OnboardException) as exc_info: 181 | nbk.ensure_device_role(create_device_role=False) 182 | self.assertEqual(exc_info.exception.message, f"ERROR device role not found: {test_role_name}") 183 | self.assertEqual(exc_info.exception.reason, "fail-config") 184 | 185 | nbk.ensure_device_role(create_device_role=True) 186 | self.assertIsInstance(nbk.nb_device_role, DeviceRole) 187 | self.assertEqual(nbk.nb_device_role.slug, slugify(test_role_name)) 188 | 189 | def test_ensure_device_role_exist(self): 190 | """Verify ensure_device_role function when DeviceRole exist but is not assigned to the OT.""" 191 | device_role = DeviceRole.objects.create(name="Firewall", slug="firewall") 192 | 193 | onboarding_kwargs = { 194 | "netdev_hostname": "device1", 195 | "netdev_nb_role_slug": device_role.slug, 196 | "netdev_nb_role_color": PLUGIN_SETTINGS["default_device_role_color"], 197 | "netdev_vendor": "Cisco", 198 | "netdev_nb_site_slug": self.site1.slug, 199 | } 200 | 201 | nbk = NetboxKeeper(**onboarding_kwargs) 202 | nbk.ensure_device_role(create_device_role=False) 203 | 204 | self.assertEqual(nbk.nb_device_role, device_role) 205 | 206 | # 207 | def test_ensure_device_role_assigned(self): 208 | """Verify ensure_device_role function when DeviceRole exist and is already assigned.""" 209 | device_role = DeviceRole.objects.create(name="Firewall", slug="firewall") 210 | 211 | onboarding_kwargs = { 212 | "netdev_hostname": "device1", 213 | "netdev_nb_role_slug": device_role.slug, 214 | "netdev_nb_role_color": PLUGIN_SETTINGS["default_device_role_color"], 215 | "netdev_vendor": "Cisco", 216 | "netdev_nb_site_slug": self.site1.slug, 217 | } 218 | 219 | nbk = NetboxKeeper(**onboarding_kwargs) 220 | nbk.ensure_device_role(create_device_role=True) 221 | 222 | self.assertEqual(nbk.nb_device_role, device_role) 223 | 224 | def test_ensure_device_instance_not_exist(self): 225 | """Verify ensure_device_instance function.""" 226 | serial_number = "123456" 227 | platform_slug = "cisco_ios" 228 | hostname = "device1" 229 | 230 | onboarding_kwargs = { 231 | "netdev_hostname": hostname, 232 | "netdev_nb_role_slug": PLUGIN_SETTINGS["default_device_role"], 233 | "netdev_nb_role_color": PLUGIN_SETTINGS["default_device_role_color"], 234 | "netdev_vendor": "Cisco", 235 | "netdev_model": "CSR1000v", 236 | "netdev_nb_site_slug": self.site1.slug, 237 | "netdev_netmiko_device_type": platform_slug, 238 | "netdev_serial_number": serial_number, 239 | "netdev_mgmt_ip_address": "192.0.2.10", 240 | "netdev_mgmt_ifname": "GigaEthernet0", 241 | "netdev_mgmt_pflen": 24, 242 | } 243 | 244 | nbk = NetboxKeeper(**onboarding_kwargs) 245 | 246 | nbk.ensure_device() 247 | 248 | self.assertIsInstance(nbk.device, Device) 249 | self.assertEqual(nbk.device.name, hostname) 250 | self.assertEqual(nbk.device.status, PLUGIN_SETTINGS["default_device_status"]) 251 | self.assertEqual(nbk.device.platform.slug, platform_slug) 252 | self.assertEqual(nbk.device.serial, serial_number) 253 | 254 | def test_ensure_device_instance_exist(self): 255 | """Verify ensure_device_instance function.""" 256 | manufacturer = Manufacturer.objects.create(name="Cisco", slug="cisco") 257 | 258 | device_role = DeviceRole.objects.create(name="Switch", slug="switch") 259 | 260 | device_type = DeviceType.objects.create(slug="c2960", model="c2960", manufacturer=manufacturer) 261 | 262 | device_name = "test_name" 263 | 264 | device = Device.objects.create( 265 | name=device_name, 266 | site=self.site1, 267 | device_type=device_type, 268 | device_role=device_role, 269 | status="planned", 270 | serial="987654", 271 | ) 272 | 273 | onboarding_kwargs = { 274 | "netdev_hostname": device_name, 275 | "netdev_nb_role_slug": "switch", 276 | "netdev_vendor": "Cisco", 277 | "netdev_model": "c2960", 278 | "netdev_nb_site_slug": self.site1.slug, 279 | "netdev_netmiko_device_type": "cisco_ios", 280 | "netdev_serial_number": "123456", 281 | "netdev_mgmt_ip_address": "192.0.2.10", 282 | "netdev_mgmt_ifname": "GigaEthernet0", 283 | "netdev_mgmt_pflen": 24, 284 | } 285 | 286 | nbk = NetboxKeeper(**onboarding_kwargs) 287 | 288 | nbk.ensure_device() 289 | 290 | self.assertIsInstance(nbk.device, Device) 291 | self.assertEqual(nbk.device.pk, device.pk) 292 | 293 | self.assertEqual(nbk.device.name, device_name) 294 | self.assertEqual(nbk.device.platform.slug, "cisco_ios") 295 | self.assertEqual(nbk.device.serial, "123456") 296 | 297 | def test_ensure_interface_not_exist(self): 298 | """Verify ensure_interface function when the interface do not exist.""" 299 | onboarding_kwargs = { 300 | "netdev_hostname": "device1", 301 | "netdev_nb_role_slug": PLUGIN_SETTINGS["default_device_role"], 302 | "netdev_nb_role_color": PLUGIN_SETTINGS["default_device_role_color"], 303 | "netdev_vendor": "Cisco", 304 | "netdev_model": "CSR1000v", 305 | "netdev_nb_site_slug": self.site1.slug, 306 | "netdev_netmiko_device_type": "cisco_ios", 307 | "netdev_serial_number": "123456", 308 | "netdev_mgmt_ip_address": "192.0.2.10", 309 | "netdev_mgmt_ifname": "ge-0/0/0", 310 | "netdev_mgmt_pflen": 24, 311 | } 312 | 313 | nbk = NetboxKeeper(**onboarding_kwargs) 314 | nbk.ensure_device() 315 | 316 | self.assertIsInstance(nbk.nb_mgmt_ifname, Interface) 317 | self.assertEqual(nbk.nb_mgmt_ifname.name, "ge-0/0/0") 318 | 319 | def test_ensure_interface_exist(self): 320 | """Verify ensure_interface function when the interface already exist.""" 321 | manufacturer = Manufacturer.objects.create(name="Cisco", slug="cisco") 322 | 323 | device_role = DeviceRole.objects.create(name="Switch", slug="switch") 324 | 325 | device_type = DeviceType.objects.create(slug="c2960", model="c2960", manufacturer=manufacturer) 326 | 327 | device_name = "test_name" 328 | netdev_mgmt_ifname = "GigaEthernet0" 329 | 330 | device = Device.objects.create( 331 | name=device_name, 332 | site=self.site1, 333 | device_type=device_type, 334 | device_role=device_role, 335 | status="planned", 336 | serial="987654", 337 | ) 338 | 339 | intf = Interface.objects.create(name=netdev_mgmt_ifname, device=device) 340 | 341 | onboarding_kwargs = { 342 | "netdev_hostname": device_name, 343 | "netdev_nb_role_slug": "switch", 344 | "netdev_vendor": "Cisco", 345 | "netdev_model": "c2960", 346 | "netdev_nb_site_slug": self.site1.slug, 347 | "netdev_netmiko_device_type": "cisco_ios", 348 | "netdev_serial_number": "123456", 349 | "netdev_mgmt_ip_address": "192.0.2.10", 350 | "netdev_mgmt_ifname": netdev_mgmt_ifname, 351 | "netdev_mgmt_pflen": 24, 352 | } 353 | 354 | nbk = NetboxKeeper(**onboarding_kwargs) 355 | 356 | nbk.ensure_device() 357 | 358 | self.assertEqual(nbk.nb_mgmt_ifname, intf) 359 | 360 | def test_ensure_primary_ip_not_exist(self): 361 | """Verify ensure_primary_ip function when the IP address do not already exist.""" 362 | onboarding_kwargs = { 363 | "netdev_hostname": "device1", 364 | "netdev_nb_role_slug": PLUGIN_SETTINGS["default_device_role"], 365 | "netdev_nb_role_color": PLUGIN_SETTINGS["default_device_role_color"], 366 | "netdev_vendor": "Cisco", 367 | "netdev_model": "CSR1000v", 368 | "netdev_nb_site_slug": self.site1.slug, 369 | "netdev_netmiko_device_type": "cisco_ios", 370 | "netdev_serial_number": "123456", 371 | "netdev_mgmt_ip_address": "192.0.2.10", 372 | "netdev_mgmt_ifname": "ge-0/0/0", 373 | "netdev_mgmt_pflen": 24, 374 | } 375 | 376 | nbk = NetboxKeeper(**onboarding_kwargs) 377 | nbk.ensure_device() 378 | 379 | self.assertIsInstance(nbk.nb_primary_ip, IPAddress) 380 | self.assertIn(nbk.nb_primary_ip, Interface.objects.get(device=nbk.device, name="ge-0/0/0").ip_addresses.all()) 381 | self.assertEqual(nbk.device.primary_ip, nbk.nb_primary_ip) 382 | 383 | def test_ensure_device_platform_missing(self): 384 | """Verify ensure_device_platform function when Platform object is not present.""" 385 | platform_name = "cisco_ios" 386 | 387 | onboarding_kwargs = { 388 | "netdev_hostname": "device1", 389 | "netdev_nb_role_slug": PLUGIN_SETTINGS["default_device_role"], 390 | "netdev_vendor": "Cisco", 391 | "netdev_model": "CSR1000v", 392 | "netdev_nb_site_slug": self.site1.slug, 393 | "netdev_nb_platform_slug": platform_name, 394 | "netdev_netmiko_device_type": platform_name, 395 | } 396 | 397 | nbk = NetboxKeeper(**onboarding_kwargs) 398 | 399 | with self.assertRaises(OnboardException) as exc_info: 400 | nbk.ensure_device_platform(create_platform_if_missing=False) 401 | self.assertEqual(exc_info.exception.message, f"ERROR device platform not found: {platform_name}") 402 | self.assertEqual(exc_info.exception.reason, "fail-config") 403 | 404 | nbk.ensure_device_platform(create_platform_if_missing=True) 405 | self.assertIsInstance(nbk.nb_platform, Platform) 406 | self.assertEqual(nbk.nb_platform.slug, slugify(platform_name)) 407 | 408 | def test_ensure_platform_present(self): 409 | """Verify ensure_device_platform function when Platform object is present.""" 410 | platform_name = "juniper_junos" 411 | 412 | manufacturer = Manufacturer.objects.create(name="Juniper", slug="juniper") 413 | 414 | device_type = DeviceType.objects.create(slug="srx3600", model="SRX3600", manufacturer=manufacturer) 415 | 416 | platform = Platform.objects.create(slug=platform_name, name=platform_name,) 417 | 418 | onboarding_kwargs = { 419 | "netdev_hostname": "device2", 420 | "netdev_nb_role_slug": PLUGIN_SETTINGS["default_device_role"], 421 | "netdev_vendor": "Juniper", 422 | "netdev_nb_device_type_slug": device_type.slug, 423 | "netdev_nb_site_slug": self.site1.slug, 424 | "netdev_nb_platform_slug": platform_name, 425 | } 426 | 427 | nbk = NetboxKeeper(**onboarding_kwargs) 428 | 429 | nbk.ensure_device_platform(create_platform_if_missing=False) 430 | 431 | self.assertIsInstance(nbk.nb_platform, Platform) 432 | self.assertEqual(nbk.nb_platform, platform) 433 | self.assertEqual(nbk.nb_platform.slug, slugify(platform_name)) 434 | 435 | def test_platform_map(self): 436 | """Verify platform mapping of netmiko to slug functionality.""" 437 | # Create static mapping 438 | PLUGIN_SETTINGS["platform_map"] = {"cisco_ios": "ios", "arista_eos": "eos", "cisco_nxos": "cisco-nxos"} 439 | 440 | onboarding_kwargs = { 441 | "netdev_hostname": "device1", 442 | "netdev_nb_role_slug": PLUGIN_SETTINGS["default_device_role"], 443 | "netdev_vendor": "Cisco", 444 | "netdev_model": "CSR1000v", 445 | "netdev_nb_site_slug": self.site1.slug, 446 | "netdev_netmiko_device_type": "cisco_ios", 447 | } 448 | 449 | nbk = NetboxKeeper(**onboarding_kwargs) 450 | 451 | nbk.ensure_device_platform(create_platform_if_missing=True) 452 | self.assertIsInstance(nbk.nb_platform, Platform) 453 | self.assertEqual(nbk.nb_platform.slug, slugify(PLUGIN_SETTINGS["platform_map"]["cisco_ios"])) 454 | self.assertEqual( 455 | Platform.objects.get(name=PLUGIN_SETTINGS["platform_map"]["cisco_ios"]).name, 456 | slugify(PLUGIN_SETTINGS["platform_map"]["cisco_ios"]), 457 | ) 458 | -------------------------------------------------------------------------------- /netbox_onboarding/tests/test_netdev_keeper.py: -------------------------------------------------------------------------------- 1 | """Unit tests for netbox_onboarding.netdev_keeper module and its classes. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | from socket import gaierror 16 | from unittest import mock 17 | 18 | from django.test import TestCase 19 | from dcim.models import Site, DeviceRole, Platform 20 | 21 | from netbox_onboarding.exceptions import OnboardException 22 | from netbox_onboarding.helpers import onboarding_task_fqdn_to_ip 23 | from netbox_onboarding.models import OnboardingTask 24 | 25 | 26 | class NetdevKeeperTestCase(TestCase): 27 | """Test the NetdevKeeper Class.""" 28 | 29 | def setUp(self): 30 | """Create a superuser and token for API calls.""" 31 | self.site1 = Site.objects.create(name="USWEST", slug="uswest") 32 | self.device_role1 = DeviceRole.objects.create(name="Firewall", slug="firewall") 33 | 34 | self.platform1 = Platform.objects.create(name="JunOS", slug="junos", napalm_driver="junos") 35 | # self.platform2 = Platform.objects.create(name="Cisco NX-OS", slug="cisco-nx-os") 36 | 37 | self.onboarding_task4 = OnboardingTask.objects.create( 38 | ip_address="ntc123.local", site=self.site1, role=self.device_role1, platform=self.platform1 39 | ) 40 | 41 | self.onboarding_task5 = OnboardingTask.objects.create( 42 | ip_address="bad.local", site=self.site1, role=self.device_role1, platform=self.platform1 43 | ) 44 | 45 | self.onboarding_task7 = OnboardingTask.objects.create( 46 | ip_address="192.0.2.1/32", site=self.site1, role=self.device_role1, platform=self.platform1 47 | ) 48 | 49 | @mock.patch("netbox_onboarding.helpers.socket.gethostbyname") 50 | def test_check_ip(self, mock_get_hostbyname): 51 | """Check DNS to IP address.""" 52 | # Look up response value 53 | mock_get_hostbyname.return_value = "192.0.2.1" 54 | 55 | # FQDN -> IP 56 | onboarding_task_fqdn_to_ip(ot=self.onboarding_task4) 57 | 58 | # Run the check to change the IP address 59 | self.assertEqual(self.onboarding_task4.ip_address, "192.0.2.1") 60 | 61 | @mock.patch("netbox_onboarding.helpers.socket.gethostbyname") 62 | def test_failed_check_ip(self, mock_get_hostbyname): 63 | """Check DNS to IP address failing.""" 64 | # Look up a failed response 65 | mock_get_hostbyname.side_effect = gaierror(8) 66 | 67 | # Check for bad.local raising an exception 68 | with self.assertRaises(OnboardException) as exc_info: 69 | onboarding_task_fqdn_to_ip(ot=self.onboarding_task5) 70 | self.assertEqual(exc_info.exception.message, "ERROR failed to complete DNS lookup: bad.local") 71 | self.assertEqual(exc_info.exception.reason, "fail-dns") 72 | 73 | # Check for exception with prefix address entered 74 | with self.assertRaises(OnboardException) as exc_info: 75 | onboarding_task_fqdn_to_ip(ot=self.onboarding_task7) 76 | self.assertEqual(exc_info.exception.reason, "fail-prefix") 77 | self.assertEqual(exc_info.exception.message, "ERROR appears a prefix was entered: 192.0.2.1/32") 78 | -------------------------------------------------------------------------------- /netbox_onboarding/tests/test_views_28.py: -------------------------------------------------------------------------------- 1 | """Unit tests for netbox_onboarding views. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | from dcim.models import Site 15 | from utilities.testing import ViewTestCases 16 | 17 | from netbox_onboarding.models import OnboardingTask 18 | from netbox_onboarding.release import NETBOX_RELEASE_CURRENT, NETBOX_RELEASE_29 19 | 20 | 21 | if NETBOX_RELEASE_CURRENT < NETBOX_RELEASE_29: 22 | 23 | class OnboardingTestCase( 24 | ViewTestCases.GetObjectViewTestCase, 25 | ViewTestCases.ListObjectsViewTestCase, 26 | ViewTestCases.CreateObjectViewTestCase, 27 | ViewTestCases.BulkDeleteObjectsViewTestCase, 28 | ViewTestCases.ImportObjectsViewTestCase, # pylint: disable=no-member 29 | ): 30 | """Test the OnboardingTask views.""" 31 | 32 | def _get_base_url(self): 33 | return "plugins:{}:{}_{{}}".format(self.model._meta.app_label, self.model._meta.model_name) 34 | 35 | model = OnboardingTask 36 | 37 | @classmethod 38 | def setUpTestData(cls): # pylint: disable=invalid-name, missing-function-docstring 39 | """Setup test data.""" 40 | site = Site.objects.create(name="USWEST", slug="uswest") 41 | OnboardingTask.objects.create(ip_address="10.10.10.10", site=site) 42 | OnboardingTask.objects.create(ip_address="192.168.1.1", site=site) 43 | 44 | cls.form_data = { 45 | "site": site.pk, 46 | "ip_address": "192.0.2.99", 47 | "port": 22, 48 | "timeout": 30, 49 | } 50 | 51 | cls.csv_data = ( 52 | "site,ip_address", 53 | "uswest,10.10.10.10", 54 | "uswest,10.10.10.20", 55 | "uswest,10.10.10.30", 56 | ) 57 | -------------------------------------------------------------------------------- /netbox_onboarding/tests/test_views_29.py: -------------------------------------------------------------------------------- 1 | """Unit tests for netbox_onboarding views. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | from dcim.models import Site 15 | from utilities.testing import ViewTestCases 16 | 17 | from netbox_onboarding.models import OnboardingTask 18 | from netbox_onboarding.release import NETBOX_RELEASE_CURRENT, NETBOX_RELEASE_29 19 | 20 | 21 | if NETBOX_RELEASE_CURRENT >= NETBOX_RELEASE_29: 22 | 23 | class OnboardingTestCase( 24 | ViewTestCases.GetObjectViewTestCase, 25 | ViewTestCases.ListObjectsViewTestCase, 26 | ViewTestCases.CreateObjectViewTestCase, 27 | ViewTestCases.BulkDeleteObjectsViewTestCase, 28 | ViewTestCases.BulkImportObjectsViewTestCase, # pylint: disable=no-member 29 | ): 30 | """Test the OnboardingTask views.""" 31 | 32 | def _get_base_url(self): 33 | return "plugins:{}:{}_{{}}".format(self.model._meta.app_label, self.model._meta.model_name) 34 | 35 | model = OnboardingTask 36 | 37 | @classmethod 38 | def setUpTestData(cls): # pylint: disable=invalid-name, missing-function-docstring 39 | """Setup test data.""" 40 | site = Site.objects.create(name="USWEST", slug="uswest") 41 | OnboardingTask.objects.create(ip_address="10.10.10.10", site=site) 42 | OnboardingTask.objects.create(ip_address="192.168.1.1", site=site) 43 | 44 | cls.form_data = { 45 | "site": site.pk, 46 | "ip_address": "192.0.2.99", 47 | "port": 22, 48 | "timeout": 30, 49 | } 50 | 51 | cls.csv_data = ( 52 | "site,ip_address", 53 | "uswest,10.10.10.10", 54 | "uswest,10.10.10.20", 55 | "uswest,10.10.10.30", 56 | ) 57 | -------------------------------------------------------------------------------- /netbox_onboarding/urls.py: -------------------------------------------------------------------------------- 1 | """Django urlpatterns declaration for netbox_onboarding plugin. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | from django.urls import path 15 | from extras.views import ObjectChangeLogView 16 | 17 | from .models import OnboardingTask 18 | from .views import ( 19 | OnboardingTaskView, 20 | OnboardingTaskListView, 21 | OnboardingTaskCreateView, 22 | OnboardingTaskBulkDeleteView, 23 | OnboardingTaskFeedBulkImportView, 24 | ) 25 | 26 | urlpatterns = [ 27 | path("", OnboardingTaskListView.as_view(), name="onboardingtask_list"), 28 | path("/", OnboardingTaskView.as_view(), name="onboardingtask"), 29 | path("add/", OnboardingTaskCreateView.as_view(), name="onboardingtask_add"), 30 | path("delete/", OnboardingTaskBulkDeleteView.as_view(), name="onboardingtask_bulk_delete"), 31 | path("import/", OnboardingTaskFeedBulkImportView.as_view(), name="onboardingtask_import"), 32 | path( 33 | "/changelog/", 34 | ObjectChangeLogView.as_view(), 35 | name="onboardingtask_changelog", 36 | kwargs={"model": OnboardingTask}, 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /netbox_onboarding/utils/credentials.py: -------------------------------------------------------------------------------- 1 | """User credentials helper module for device onboarding. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | 16 | class Credentials: 17 | """Class used to hide user's credentials in RQ worker and Django.""" 18 | 19 | def __init__(self, username=None, password=None, secret=None): 20 | """Create a Credentials instance.""" 21 | self.username = username 22 | self.password = password 23 | self.secret = secret 24 | 25 | def __repr__(self): 26 | """Return string representation of a Credentials object.""" 27 | return "*Credentials argument hidden*" 28 | -------------------------------------------------------------------------------- /netbox_onboarding/views.py: -------------------------------------------------------------------------------- 1 | """Django views for device onboarding. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | import logging 15 | 16 | from django.shortcuts import get_object_or_404, render 17 | 18 | 19 | from .release import NETBOX_RELEASE_CURRENT, NETBOX_RELEASE_29, NETBOX_RELEASE_210 20 | from .filters import OnboardingTaskFilter 21 | from .forms import OnboardingTaskForm, OnboardingTaskFilterForm, OnboardingTaskFeedCSVForm 22 | from .models import OnboardingTask 23 | from .tables import OnboardingTaskTable, OnboardingTaskFeedBulkTable 24 | 25 | logger = logging.getLogger("rq.worker") 26 | 27 | # pylint: disable=ungrouped-imports,no-name-in-module 28 | 29 | if NETBOX_RELEASE_CURRENT < NETBOX_RELEASE_29: 30 | from django.contrib.auth.mixins import PermissionRequiredMixin 31 | from django.views.generic import View 32 | from utilities.views import BulkDeleteView, BulkImportView, ObjectEditView, ObjectListView 33 | 34 | class ReleaseMixinOnboardingTaskView(PermissionRequiredMixin, View): 35 | """Release Mixin View for presenting a single OnboardingTask.""" 36 | 37 | permission_required = "netbox_onboarding.view_onboardingtask" 38 | 39 | class ReleaseMixinOnboardingTaskListView(PermissionRequiredMixin, ObjectListView): 40 | """Release Mixin View for listing all extant OnboardingTasks.""" 41 | 42 | permission_required = "netbox_onboarding.view_onboardingtask" 43 | 44 | class ReleaseMixinOnboardingTaskCreateView(PermissionRequiredMixin, ObjectEditView): 45 | """Release Mixin View for creating a new OnboardingTask.""" 46 | 47 | permission_required = "netbox_onboarding.add_onboardingtask" 48 | 49 | class ReleaseMixinOnboardingTaskBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): 50 | """Release Mixin View for deleting one or more OnboardingTasks.""" 51 | 52 | permission_required = "netbox_onboarding.delete_onboardingtask" 53 | 54 | class ReleaseMixinOnboardingTaskFeedBulkImportView(PermissionRequiredMixin, BulkImportView): 55 | """Release Mixin View for bulk-importing a CSV file to create OnboardingTasks.""" 56 | 57 | permission_required = "netbox_onboarding.add_onboardingtask" 58 | 59 | 60 | elif NETBOX_RELEASE_29 <= NETBOX_RELEASE_CURRENT < NETBOX_RELEASE_210: 61 | from utilities.views import ObjectView, BulkDeleteView, BulkImportView, ObjectEditView, ObjectListView 62 | 63 | class ReleaseMixinOnboardingTaskView(ObjectView): 64 | """Release Mixin View for presenting a single OnboardingTask.""" 65 | 66 | class ReleaseMixinOnboardingTaskListView(ObjectListView): 67 | """Release Mixin View for listing all extant OnboardingTasks.""" 68 | 69 | class ReleaseMixinOnboardingTaskCreateView(ObjectEditView): 70 | """Release Mixin View for creating a new OnboardingTask.""" 71 | 72 | class ReleaseMixinOnboardingTaskBulkDeleteView(BulkDeleteView): 73 | """Release Mixin View for deleting one or more OnboardingTasks.""" 74 | 75 | class ReleaseMixinOnboardingTaskFeedBulkImportView(BulkImportView): 76 | """Release Mixin View for bulk-importing a CSV file to create OnboardingTasks.""" 77 | 78 | 79 | elif NETBOX_RELEASE_CURRENT >= NETBOX_RELEASE_210: 80 | from netbox.views import generic 81 | 82 | # ObjectView, BulkDeleteView, BulkImportView, ObjectEditView, ObjectListView 83 | 84 | class ReleaseMixinOnboardingTaskView(generic.ObjectView): 85 | """Release Mixin View for presenting a single OnboardingTask.""" 86 | 87 | class ReleaseMixinOnboardingTaskListView(generic.ObjectListView): 88 | """Release Mixin View for listing all extant OnboardingTasks.""" 89 | 90 | class ReleaseMixinOnboardingTaskCreateView(generic.ObjectEditView): 91 | """Release Mixin View for creating a new OnboardingTask.""" 92 | 93 | class ReleaseMixinOnboardingTaskBulkDeleteView(generic.BulkDeleteView): 94 | """Release Mixin View for deleting one or more OnboardingTasks.""" 95 | 96 | class ReleaseMixinOnboardingTaskFeedBulkImportView(generic.BulkImportView): 97 | """Release Mixin View for bulk-importing a CSV file to create OnboardingTasks.""" 98 | 99 | 100 | class OnboardingTaskView(ReleaseMixinOnboardingTaskView): 101 | """View for presenting a single OnboardingTask.""" 102 | 103 | queryset = OnboardingTask.objects.all() 104 | 105 | def get(self, request, pk): # pylint: disable=invalid-name, missing-function-docstring 106 | """Get request.""" 107 | instance = get_object_or_404(self.queryset, pk=pk) 108 | 109 | return render( 110 | request, "netbox_onboarding/onboardingtask.html", {"object": instance, "onboardingtask": instance} 111 | ) 112 | 113 | 114 | class OnboardingTaskListView(ReleaseMixinOnboardingTaskListView): 115 | """View for listing all extant OnboardingTasks.""" 116 | 117 | queryset = OnboardingTask.objects.all().order_by("-id") 118 | filterset = OnboardingTaskFilter 119 | filterset_form = OnboardingTaskFilterForm 120 | table = OnboardingTaskTable 121 | template_name = "netbox_onboarding/onboarding_tasks_list.html" 122 | 123 | 124 | class OnboardingTaskCreateView(ReleaseMixinOnboardingTaskCreateView): 125 | """View for creating a new OnboardingTask.""" 126 | 127 | model = OnboardingTask 128 | queryset = OnboardingTask.objects.all() 129 | model_form = OnboardingTaskForm 130 | template_name = "netbox_onboarding/onboarding_task_edit.html" 131 | default_return_url = "plugins:netbox_onboarding:onboardingtask_list" 132 | 133 | 134 | class OnboardingTaskBulkDeleteView(ReleaseMixinOnboardingTaskBulkDeleteView): 135 | """View for deleting one or more OnboardingTasks.""" 136 | 137 | queryset = OnboardingTask.objects.filter() # TODO: can we exclude currently-running tasks? 138 | table = OnboardingTaskTable 139 | default_return_url = "plugins:netbox_onboarding:onboardingtask_list" 140 | 141 | 142 | class OnboardingTaskFeedBulkImportView(ReleaseMixinOnboardingTaskFeedBulkImportView): 143 | """View for bulk-importing a CSV file to create OnboardingTasks.""" 144 | 145 | queryset = OnboardingTask.objects.all() 146 | model_form = OnboardingTaskFeedCSVForm 147 | table = OnboardingTaskFeedBulkTable 148 | default_return_url = "plugins:netbox_onboarding:onboardingtask_list" 149 | -------------------------------------------------------------------------------- /netbox_onboarding/worker.py: -------------------------------------------------------------------------------- 1 | """Worker code for processing inbound OnboardingTasks. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | import logging 15 | 16 | from django.core.exceptions import ValidationError 17 | from django_rq import job 18 | from prometheus_client import Summary 19 | 20 | from dcim.models import Device 21 | 22 | from .choices import OnboardingFailChoices 23 | from .choices import OnboardingStatusChoices 24 | from .exceptions import OnboardException 25 | from .helpers import onboarding_task_fqdn_to_ip 26 | from .metrics import onboardingtask_results_counter 27 | from .models import OnboardingDevice 28 | from .models import OnboardingTask 29 | from .onboard import OnboardingManager 30 | 31 | logger = logging.getLogger("rq.worker") 32 | 33 | 34 | REQUEST_TIME = Summary("onboardingtask_processing_seconds", "Time spent processing onboarding request") 35 | 36 | 37 | @REQUEST_TIME.time() 38 | @job("default") 39 | def onboard_device(task_id, credentials): # pylint: disable=too-many-statements, too-many-branches 40 | """Process a single OnboardingTask instance.""" 41 | username = credentials.username 42 | password = credentials.password 43 | secret = credentials.secret 44 | 45 | ot = OnboardingTask.objects.get(id=task_id) 46 | 47 | # Rewrite FQDN to IP for Onboarding Task 48 | onboarding_task_fqdn_to_ip(ot) 49 | 50 | logger.info("START: onboard device") 51 | onboarded_device = None 52 | 53 | try: 54 | try: 55 | if ot.ip_address: 56 | onboarded_device = Device.objects.get(primary_ip4__address__net_host=ot.ip_address) 57 | 58 | if OnboardingDevice.objects.filter(device=onboarded_device, enabled=False): 59 | ot.status = OnboardingStatusChoices.STATUS_SKIPPED 60 | 61 | return dict(ok=True) 62 | 63 | except Device.DoesNotExist as exc: 64 | logger.info("Getting device with IP lookup failed: %s", str(exc)) 65 | except Device.MultipleObjectsReturned as exc: 66 | logger.info("Getting device with IP lookup failed: %s", str(exc)) 67 | raise OnboardException( 68 | reason="fail-general", message=f"ERROR Multiple devices exist for IP {ot.ip_address}" 69 | ) 70 | except ValueError as exc: 71 | logger.info("Getting device with IP lookup failed: %s", str(exc)) 72 | except ValidationError as exc: 73 | logger.info("Getting device with IP lookup failed: %s", str(exc)) 74 | 75 | ot.status = OnboardingStatusChoices.STATUS_RUNNING 76 | ot.save() 77 | 78 | onboarding_manager = OnboardingManager(ot=ot, username=username, password=password, secret=secret) 79 | 80 | if onboarding_manager.created_device: 81 | ot.created_device = onboarding_manager.created_device 82 | 83 | ot.status = OnboardingStatusChoices.STATUS_SUCCEEDED 84 | ot.save() 85 | logger.info("FINISH: onboard device") 86 | onboarding_status = True 87 | 88 | except OnboardException as exc: 89 | if onboarded_device: 90 | ot.created_device = onboarded_device 91 | 92 | logger.error("%s", exc) 93 | ot.status = OnboardingStatusChoices.STATUS_FAILED 94 | ot.failed_reason = exc.reason 95 | ot.message = exc.message 96 | ot.save() 97 | onboarding_status = False 98 | 99 | except Exception as exc: # pylint: disable=broad-except 100 | if onboarded_device: 101 | ot.created_device = onboarded_device 102 | 103 | logger.error("Onboarding Error - Exception") 104 | logger.error(str(exc)) 105 | ot.status = OnboardingStatusChoices.STATUS_FAILED 106 | ot.failed_reason = OnboardingFailChoices.FAIL_GENERAL 107 | ot.message = str(exc) 108 | ot.save() 109 | onboarding_status = False 110 | 111 | finally: 112 | if onboarded_device and not OnboardingDevice.objects.filter(device=onboarded_device): 113 | OnboardingDevice.objects.create(device=onboarded_device) 114 | 115 | onboardingtask_results_counter.labels(status=ot.status).inc() 116 | 117 | return dict(ok=onboarding_status) 118 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "ntc-netbox-plugin-onboarding" 3 | version = "2.2.0" 4 | description = "A plugin for NetBox to easily onboard new devices." 5 | authors = ["Network to Code, LLC "] 6 | license = "Apache-2.0" 7 | readme = "README.md" 8 | homepage = "https://github.com/networktocode/ntc-netbox-plugin-onboarding" 9 | repository = "https://github.com/networktocode/ntc-netbox-plugin-onboarding" 10 | keywords = ["netbox", "network", "onboarding", "django"] 11 | include = [ 12 | "LICENSE", 13 | "README.md", 14 | "docs/images/*", 15 | ] 16 | packages = [ 17 | { include = "netbox_onboarding" }, 18 | ] 19 | 20 | [tool.poetry.dependencies] 21 | python = "^3.6 || ^3.7 || ^3.8" 22 | napalm = ">=2.5.0, <4" 23 | zipp = "^3.4.0" 24 | 25 | [tool.poetry.dev-dependencies] 26 | invoke = "^1.4.1" 27 | black = "^19.10b0" 28 | yamllint = "^1.23.0" 29 | bandit = "^1.6.2" 30 | pylint = "^2.5.2" 31 | pylint-django = "^2.0.15" 32 | pydocstyle = "^5.0.2" 33 | 34 | [tool.black] 35 | line-length = 120 36 | target-version = ['py36'] 37 | include = '\.pyi?$' 38 | exclude = ''' 39 | ( 40 | /( 41 | \.eggs # exclude a few common directories in the 42 | | \.git # root of the project 43 | | \.hg 44 | | \.mypy_cache 45 | | \.tox 46 | | \.venv 47 | | _build 48 | | buck-out 49 | | build 50 | | dist 51 | )/ 52 | | settings.py # This is where you define files that should not be stylized by black 53 | # the root of the project 54 | ) 55 | ''' 56 | 57 | [tool.pylint.master] 58 | # Include the pylint_django plugin to avoid spurious warnings about Django patterns 59 | load-plugins="pylint_django" 60 | 61 | # Don't cache data for later comparisons 62 | persistent="no" 63 | 64 | [tool.pylint.basic] 65 | # No docstrings required for private methods (Pylint default), or for test_ functions, or for inner Meta classes. 66 | no-docstring-rgx="^(_|test_|Meta$)" 67 | # Allow "ot" (OnboardingTask instance) as an acceptable variable name 68 | good-names="ot," 69 | 70 | [tool.pylint.messages_control] 71 | # Line length is enforced by Black, so pylint doesn't need to check it. 72 | # Pylint and Black disagree about how to format multi-line arrays; Black wins. 73 | # Disabled due to noise: too-few-public-methods, too-many-ancestors, too-many-instance-attributes 74 | disable = """, 75 | line-too-long, 76 | bad-continuation, 77 | too-few-public-methods, 78 | too-many-ancestors, 79 | too-many-instance-attributes, 80 | duplicate-code, 81 | raise-missing-from, 82 | super-with-arguments 83 | """ 84 | 85 | [tool.pylint.miscellaneous] 86 | # Don't flag TODO as a failure, let us commit with things that still need to be done in the code 87 | notes = """, 88 | FIXME, 89 | XXX, 90 | """ 91 | 92 | [build-system] 93 | requires = ["poetry>=0.12"] 94 | build-backend = "poetry.masonry.api" 95 | 96 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | """Tasks for use with Invoke. 2 | 3 | (c) 2020 Network To Code 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | """ 14 | 15 | import os 16 | from invoke import task 17 | 18 | PYTHON_VER = os.getenv("PYTHON_VER", "3.7") 19 | NETBOX_VER = os.getenv("NETBOX_VER", "master") 20 | 21 | # Name of the docker image/container 22 | NAME = os.getenv("IMAGE_NAME", "ntc-netbox-plugin-onboarding") 23 | PWD = os.getcwd() 24 | 25 | COMPOSE_FILE = "development/docker-compose.yml" 26 | BUILD_NAME = "netbox_onboarding" 27 | 28 | 29 | # ------------------------------------------------------------------------------ 30 | # BUILD 31 | # ------------------------------------------------------------------------------ 32 | @task 33 | def build(context, netbox_ver=NETBOX_VER, python_ver=PYTHON_VER): 34 | """Build all docker images. 35 | 36 | Args: 37 | context (obj): Used to run specific commands 38 | netbox_ver (str): NetBox version to use to build the container 39 | python_ver (str): Will use the Python version docker image to build from 40 | """ 41 | context.run( 42 | f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} build --build-arg netbox_ver={netbox_ver} --build-arg python_ver={python_ver}", 43 | env={"NETBOX_VER": netbox_ver, "PYTHON_VER": python_ver}, 44 | ) 45 | 46 | 47 | # ------------------------------------------------------------------------------ 48 | # START / STOP / DEBUG 49 | # ------------------------------------------------------------------------------ 50 | @task 51 | def debug(context, netbox_ver=NETBOX_VER, python_ver=PYTHON_VER): 52 | """Start NetBox and its dependencies in debug mode. 53 | 54 | Args: 55 | context (obj): Used to run specific commands 56 | netbox_ver (str): NetBox version to use to build the container 57 | python_ver (str): Will use the Python version docker image to build from 58 | """ 59 | print("Starting Netbox .. ") 60 | context.run( 61 | f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} up", 62 | env={"NETBOX_VER": netbox_ver, "PYTHON_VER": python_ver}, 63 | ) 64 | 65 | 66 | @task 67 | def start(context, netbox_ver=NETBOX_VER, python_ver=PYTHON_VER): 68 | """Start NetBox and its dependencies in detached mode. 69 | 70 | Args: 71 | context (obj): Used to run specific commands 72 | netbox_ver (str): NetBox version to use to build the container 73 | python_ver (str): Will use the Python version docker image to build from 74 | """ 75 | print("Starting Netbox in detached mode.. ") 76 | context.run( 77 | f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} up -d", 78 | env={"NETBOX_VER": netbox_ver, "PYTHON_VER": python_ver}, 79 | ) 80 | 81 | 82 | @task 83 | def stop(context, netbox_ver=NETBOX_VER, python_ver=PYTHON_VER): 84 | """Stop NetBox and its dependencies. 85 | 86 | Args: 87 | context (obj): Used to run specific commands 88 | netbox_ver (str): NetBox version to use to build the container 89 | python_ver (str): Will use the Python version docker image to build from 90 | """ 91 | print("Stopping Netbox .. ") 92 | context.run( 93 | f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} down", 94 | env={"NETBOX_VER": netbox_ver, "PYTHON_VER": python_ver}, 95 | ) 96 | 97 | 98 | @task 99 | def destroy(context, netbox_ver=NETBOX_VER, python_ver=PYTHON_VER): 100 | """Destroy all containers and volumes. 101 | 102 | Args: 103 | context (obj): Used to run specific commands 104 | netbox_ver (str): NetBox version to use to build the container 105 | python_ver (str): Will use the Python version docker image to build from 106 | """ 107 | context.run( 108 | f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} down", 109 | env={"NETBOX_VER": netbox_ver, "PYTHON_VER": python_ver}, 110 | ) 111 | context.run( 112 | f"docker volume rm -f {BUILD_NAME}_pgdata_netbox_onboarding", 113 | env={"NETBOX_VER": netbox_ver, "PYTHON_VER": python_ver}, 114 | ) 115 | 116 | 117 | # ------------------------------------------------------------------------------ 118 | # ACTIONS 119 | # ------------------------------------------------------------------------------ 120 | @task 121 | def nbshell(context, netbox_ver=NETBOX_VER, python_ver=PYTHON_VER): 122 | """Launch a nbshell session. 123 | 124 | Args: 125 | context (obj): Used to run specific commands 126 | netbox_ver (str): NetBox version to use to build the container 127 | python_ver (str): Will use the Python version docker image to build from 128 | """ 129 | context.run( 130 | f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} run netbox python manage.py nbshell", 131 | env={"NETBOX_VER": netbox_ver, "PYTHON_VER": python_ver}, 132 | pty=True, 133 | ) 134 | 135 | 136 | @task 137 | def cli(context, netbox_ver=NETBOX_VER, python_ver=PYTHON_VER): 138 | """Launch a bash shell inside the running NetBox container. 139 | 140 | Args: 141 | context (obj): Used to run specific commands 142 | netbox_ver (str): NetBox version to use to build the container 143 | python_ver (str): Will use the Python version docker image to build from 144 | """ 145 | context.run( 146 | f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} run netbox bash", 147 | env={"NETBOX_VER": netbox_ver, "PYTHON_VER": python_ver}, 148 | pty=True, 149 | ) 150 | 151 | 152 | @task 153 | def create_user(context, user="admin", netbox_ver=NETBOX_VER, python_ver=PYTHON_VER): 154 | """Create a new user in django (default: admin), will prompt for password. 155 | 156 | Args: 157 | context (obj): Used to run specific commands 158 | user (str): name of the superuser to create 159 | netbox_ver (str): NetBox version to use to build the container 160 | python_ver (str): Will use the Python version docker image to build from 161 | """ 162 | context.run( 163 | f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} run netbox python manage.py createsuperuser --username {user}", 164 | env={"NETBOX_VER": netbox_ver, "PYTHON_VER": python_ver}, 165 | pty=True, 166 | ) 167 | 168 | 169 | @task 170 | def makemigrations(context, name="", netbox_ver=NETBOX_VER, python_ver=PYTHON_VER): 171 | """Run Make Migration in Django. 172 | 173 | Args: 174 | context (obj): Used to run specific commands 175 | name (str): Name of the migration to be created 176 | netbox_ver (str): NetBox version to use to build the container 177 | python_ver (str): Will use the Python version docker image to build from 178 | """ 179 | context.run( 180 | f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} up -d postgres", 181 | env={"NETBOX_VER": netbox_ver, "PYTHON_VER": python_ver}, 182 | ) 183 | 184 | if name: 185 | context.run( 186 | f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} run netbox python manage.py makemigrations --name {name}", 187 | env={"NETBOX_VER": netbox_ver, "PYTHON_VER": python_ver}, 188 | ) 189 | else: 190 | context.run( 191 | f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} run netbox python manage.py makemigrations", 192 | env={"NETBOX_VER": netbox_ver, "PYTHON_VER": python_ver}, 193 | ) 194 | 195 | context.run( 196 | f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} down", 197 | env={"NETBOX_VER": netbox_ver, "PYTHON_VER": python_ver}, 198 | ) 199 | 200 | 201 | # ------------------------------------------------------------------------------ 202 | # TESTS / LINTING 203 | # ------------------------------------------------------------------------------ 204 | @task 205 | def unittest(context, netbox_ver=NETBOX_VER, python_ver=PYTHON_VER): 206 | """Run Django unit tests for the plugin. 207 | 208 | Args: 209 | context (obj): Used to run specific commands 210 | netbox_ver (str): NetBox version to use to build the container 211 | python_ver (str): Will use the Python version docker image to build from 212 | """ 213 | docker = f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} run netbox" 214 | context.run( 215 | f'{docker} sh -c "python manage.py test netbox_onboarding"', 216 | env={"NETBOX_VER": netbox_ver, "PYTHON_VER": python_ver}, 217 | pty=True, 218 | ) 219 | 220 | 221 | @task 222 | def pylint(context, netbox_ver=NETBOX_VER, python_ver=PYTHON_VER): 223 | """Run pylint code analysis. 224 | 225 | Args: 226 | context (obj): Used to run specific commands 227 | netbox_ver (str): NetBox version to use to build the container 228 | python_ver (str): Will use the Python version docker image to build from 229 | """ 230 | docker = f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} run netbox" 231 | # We exclude the /migrations/ directory since it is autogenerated code 232 | context.run( 233 | f"{docker} sh -c \"cd /source && find . -name '*.py' -not -path '*/migrations/*' | " 234 | 'PYTHONPATH=/opt/netbox/netbox DJANGO_SETTINGS_MODULE=netbox.settings xargs pylint"', 235 | env={"NETBOX_VER": netbox_ver, "PYTHON_VER": python_ver}, 236 | pty=True, 237 | ) 238 | 239 | 240 | @task 241 | def black(context, netbox_ver=NETBOX_VER, python_ver=PYTHON_VER): 242 | """Run black to check that Python files adhere to its style standards. 243 | 244 | Args: 245 | context (obj): Used to run specific commands 246 | netbox_ver (str): NetBox version to use to build the container 247 | python_ver (str): Will use the Python version docker image to build from 248 | """ 249 | docker = f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} run netbox" 250 | context.run( 251 | f'{docker} sh -c "cd /source && black --check --diff ."', 252 | env={"NETBOX_VER": netbox_ver, "PYTHON_VER": python_ver}, 253 | pty=True, 254 | ) 255 | 256 | 257 | @task 258 | def pydocstyle(context, netbox_ver=NETBOX_VER, python_ver=PYTHON_VER): 259 | """Run pydocstyle to validate docstring formatting adheres to NTC defined standards. 260 | 261 | Args: 262 | context (obj): Used to run specific commands 263 | netbox_ver (str): NetBox version to use to build the container 264 | python_ver (str): Will use the Python version docker image to build from 265 | """ 266 | docker = f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} run netbox" 267 | # We exclude the /migrations/ directory since it is autogenerated code 268 | context.run( 269 | f"{docker} sh -c \"cd /source && find . -name '*.py' -not -path '*/migrations/*' | xargs pydocstyle\"", 270 | env={"NETBOX_VER": netbox_ver, "PYTHON_VER": python_ver}, 271 | pty=True, 272 | ) 273 | 274 | 275 | @task 276 | def bandit(context, netbox_ver=NETBOX_VER, python_ver=PYTHON_VER): 277 | """Run bandit to validate basic static code security analysis. 278 | 279 | Args: 280 | context (obj): Used to run specific commands 281 | netbox_ver (str): NetBox version to use to build the container 282 | python_ver (str): Will use the Python version docker image to build from 283 | """ 284 | docker = f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} run netbox" 285 | context.run( 286 | f'{docker} sh -c "cd /source && bandit --configfile .bandit.yml --recursive ./"', 287 | env={"NETBOX_VER": netbox_ver, "PYTHON_VER": python_ver}, 288 | pty=True, 289 | ) 290 | 291 | 292 | @task 293 | def tests(context, netbox_ver=NETBOX_VER, python_ver=PYTHON_VER): 294 | """Run all tests for this plugin. 295 | 296 | Args: 297 | context (obj): Used to run specific commands 298 | netbox_ver (str): NetBox version to use to build the container 299 | python_ver (str): Will use the Python version docker image to build from 300 | """ 301 | # Sorted loosely from fastest to slowest 302 | print("Running black...") 303 | black(context, netbox_ver=netbox_ver, python_ver=python_ver) 304 | print("Running bandit...") 305 | bandit(context, netbox_ver=netbox_ver, python_ver=python_ver) 306 | print("Running pydocstyle...") 307 | pydocstyle(context, netbox_ver=netbox_ver, python_ver=python_ver) 308 | print("Running pylint...") 309 | pylint(context, netbox_ver=netbox_ver, python_ver=python_ver) 310 | print("Running unit tests...") 311 | unittest(context, netbox_ver=netbox_ver, python_ver=python_ver) 312 | # print("Running yamllint...") 313 | # yamllint(context, NAME, python_ver) 314 | 315 | print("All tests have passed!") 316 | --------------------------------------------------------------------------------