From 57225dd82f333bf5bbe68ff6c4b5f4c2ee40c667 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Jul 2025 15:41:14 +0000 Subject: [PATCH 1/7] chore(deps): bump protobuf Bumps the pip group with 1 update in the /docker directory: [protobuf](https://github.com/protocolbuffers/protobuf). Updates `protobuf` from 5.28.1 to 5.29.5 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Changelog](https://github.com/protocolbuffers/protobuf/blob/main/protobuf_release.bzl) - [Commits](https://github.com/protocolbuffers/protobuf/compare/v5.28.1...v5.29.5) --- updated-dependencies: - dependency-name: protobuf dependency-version: 5.29.5 dependency-type: direct:production dependency-group: pip ... Signed-off-by: dependabot[bot] --- docker/requirements-diode-netbox-plugin.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/requirements-diode-netbox-plugin.txt b/docker/requirements-diode-netbox-plugin.txt index 644111c..5ce20bc 100644 --- a/docker/requirements-diode-netbox-plugin.txt +++ b/docker/requirements-diode-netbox-plugin.txt @@ -2,6 +2,6 @@ Brotli==1.1.0 certifi==2024.7.4 coverage==7.6.0 grpcio==1.62.1 -protobuf==5.28.1 +protobuf==5.29.5 pytest==8.0.2 netboxlabs-netbox-branching==0.5.7 \ No newline at end of file From 8ee7538f89b72d103ef2b18d762d84fab8769a25 Mon Sep 17 00:00:00 2001 From: James Jeffries Date: Mon, 14 Jul 2025 11:39:17 +0100 Subject: [PATCH 2/7] docs: add command to generate docs (#119) * adds a management command for generating matching documentation * output to the correct file location (outside of the docker container) * fix docs generation to not include django output * adds tests * fixes tests * include builtin matchers * adds order of precedence to matcher tables * linting --- Makefile | 8 + README.md | 6 + docs/matching-criteria-documentation.md | 773 ++++++++++++++++++ netbox_diode_plugin/management/__init__.py | 1 + .../management/commands/__init__.py | 1 + .../commands/generate_matching_docs.py | 269 ++++++ .../tests/test_generate_matching_docs.py | 629 ++++++++++++++ 7 files changed, 1687 insertions(+) create mode 100644 docs/matching-criteria-documentation.md create mode 100644 netbox_diode_plugin/management/__init__.py create mode 100644 netbox_diode_plugin/management/commands/__init__.py create mode 100644 netbox_diode_plugin/management/commands/generate_matching_docs.py create mode 100644 netbox_diode_plugin/tests/test_generate_matching_docs.py diff --git a/Makefile b/Makefile index 00f51a4..d6642ab 100644 --- a/Makefile +++ b/Makefile @@ -21,3 +21,11 @@ docker-compose-netbox-plugin-test: docker-compose-netbox-plugin-test-cover: -@$(DOCKER_COMPOSE) -f docker/docker-compose.yaml -f docker/docker-compose.test.yaml run --rm -u root -e COVERAGE_FILE=/opt/netbox/netbox/coverage/.coverage netbox sh -c "coverage run --source=netbox_diode_plugin --omit=*/migrations/* ./manage.py test --keepdb netbox_diode_plugin && coverage xml -o /opt/netbox/netbox/coverage/report.xml && coverage report -m | tee /opt/netbox/netbox/coverage/report.txt" @$(MAKE) docker-compose-netbox-plugin-down + +.PHONY: docker-compose-generate-matching-docs +docker-compose-generate-matching-docs: + @$(DOCKER_COMPOSE) -f docker/docker-compose.yaml -f docker/docker-compose.test.yaml run --rm netbox python manage.py generate_matching_docs | awk '/Generating markdown documentation.../{p=1;next} p' > ./docs/matching-criteria-documentation.md + +.PHONY: docker-compose-migrate +docker-compose-migrate: + @$(DOCKER_COMPOSE) -f docker/docker-compose.yaml -f docker/docker-compose.test.yaml run --rm netbox python manage.py migrate diff --git a/README.md b/README.md index fc3edda..00f085b 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,12 @@ cd /opt/netbox/netbox make docker-compose-netbox-plugin-test ``` +## Generating Documentation +Generates documentation on how diode entities are matched. The generated documentation is output to [here](./docs/matching-criteria-documentation.md). +```shell +make docker-compose-generate-matching-docs +``` + ## License Distributed under the NetBox Limited Use License 1.0. See [LICENSE.md](./LICENSE.md) for more information. diff --git a/docs/matching-criteria-documentation.md b/docs/matching-criteria-documentation.md new file mode 100644 index 0000000..1e58a24 --- /dev/null +++ b/docs/matching-criteria-documentation.md @@ -0,0 +1,773 @@ +# NetBox Diode Plugin - Object Matching Criteria + +This document describes how the Diode NetBox Plugin matches existing objects when applying changes. The matchers will be applied in the order of their precedence, unttil one of them matches. + +## Matcher Types + +- **Logical Matchers**: Custom matching criteria that represent likely user intent +- **Builtin Matchers**: Automatically generated from NetBox model constraints (unique fields, unique constraints, custom fields, auto-slugs) + +## circuits.circuit + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| circuits_circuit_unique_provider_cid | 1 | builtin | provider, cid | N/A | Matches on unique constraint fields: provider, cid | All versions | +| circuits_circuit_unique_provideraccount_cid | 2 | builtin | provider_account, cid | N/A | Matches on unique constraint fields: provider_account, cid | All versions | + +## circuits.circuitgroup + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | +| unique_slug | 2 | builtin | slug | N/A | Matches on unique field(s): slug | All versions | +| unique_autoslug_slug | 3 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## circuits.circuitgroupassignment + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| circuits_circuitgroupassignment_unique_member_group | 1 | builtin | member_type, member_id, group | N/A | Matches on unique constraint fields: member_type, member_id, group | All versions | + +## circuits.circuittermination + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| circuits_circuittermination_unique_circuit_term_side | 1 | builtin | circuit, term_side | N/A | Matches on unique constraint fields: circuit, term_side | All versions | + +## circuits.circuittype + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | +| unique_slug | 2 | builtin | slug | N/A | Matches on unique field(s): slug | All versions | +| unique_autoslug_slug | 3 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## circuits.provider + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | +| unique_slug | 2 | builtin | slug | N/A | Matches on unique field(s): slug | All versions | +| unique_autoslug_slug | 3 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## circuits.provideraccount + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| circuits_provideraccount_unique_provider_account | 1 | builtin | provider, account | N/A | Matches on unique constraint fields: provider, account | All versions | +| circuits_provideraccount_unique_provider_name | 2 | builtin | provider, name | name = | Matches on unique constraint fields: provider, name where name = | All versions | + +## circuits.providernetwork + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| circuits_providernetwork_unique_provider_name | 1 | builtin | provider, name | N/A | Matches on unique constraint fields: provider, name | All versions | + +## circuits.virtualcircuit + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| circuits_virtualcircuit_unique_provider_network_cid | 1 | builtin | provider_network, cid | N/A | Matches on unique constraint fields: provider_network, cid | All versions | +| circuits_virtualcircuit_unique_provideraccount_cid | 2 | builtin | provider_account, cid | N/A | Matches on unique constraint fields: provider_account, cid | All versions | + +## circuits.virtualcircuittermination + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_interface | 1 | builtin | interface | N/A | Matches on unique field(s): interface | All versions | + +## circuits.virtualcircuittype + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | +| unique_slug | 2 | builtin | slug | N/A | Matches on unique field(s): slug | All versions | +| unique_autoslug_slug | 3 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## dcim.cabletermination + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| dcim_cabletermination_unique_termination | 1 | builtin | termination_type, termination_id | N/A | Matches on unique constraint fields: termination_type, termination_id | All versions | + +## dcim.consoleport + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| dcim_consoleport_unique_device_name | 1 | builtin | device, name | N/A | Matches on unique constraint fields: device, name | All versions | + +## dcim.consoleporttemplate + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| dcim_consoleporttemplate_unique_device_type_name | 1 | builtin | device_type, name | N/A | Matches on unique constraint fields: device_type, name | All versions | +| dcim_consoleporttemplate_unique_module_type_name | 2 | builtin | module_type, name | N/A | Matches on unique constraint fields: module_type, name | All versions | + +## dcim.consoleserverport + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| dcim_consoleserverport_unique_device_name | 1 | builtin | device, name | N/A | Matches on unique constraint fields: device, name | All versions | + +## dcim.consoleserverporttemplate + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| dcim_consoleserverporttemplate_unique_device_type_name | 1 | builtin | device_type, name | N/A | Matches on unique constraint fields: device_type, name | All versions | +| dcim_consoleserverporttemplate_unique_module_type_name | 2 | builtin | module_type, name | N/A | Matches on unique constraint fields: module_type, name | All versions | + +## dcim.device + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_asset_tag | 1 | builtin | asset_tag | N/A | Matches on unique field(s): asset_tag | All versions | +| unique_primary_ip4 | 2 | builtin | primary_ip4 | N/A | Matches on unique field(s): primary_ip4 | All versions | +| unique_primary_ip6 | 3 | builtin | primary_ip6 | N/A | Matches on unique field(s): primary_ip6 | All versions | +| unique_oob_ip | 4 | builtin | oob_ip | N/A | Matches on unique field(s): oob_ip | All versions | +| dcim_device_unique_name_site_tenant | 5 | builtin | | N/A | Custom matcher | All versions | +| dcim_device_unique_name_site | 6 | builtin | | tenant is NULL | Custom matcher | All versions | +| dcim_device_unique_rack_position_face | 7 | builtin | rack, position, face | N/A | Matches on unique constraint fields: rack, position, face | All versions | +| dcim_device_unique_virtual_chassis_vc_position | 8 | builtin | virtual_chassis, vc_position | N/A | Matches on unique constraint fields: virtual_chassis, vc_position | All versions | + +## dcim.devicebay + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_installed_device | 1 | builtin | installed_device | N/A | Matches on unique field(s): installed_device | All versions | +| dcim_devicebay_unique_device_name | 2 | builtin | device, name | N/A | Matches on unique constraint fields: device, name | All versions | + +## dcim.devicebaytemplate + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| dcim_devicebaytemplate_unique_device_type_name | 1 | builtin | device_type, name | N/A | Matches on unique constraint fields: device_type, name | All versions | + +## dcim.devicerole + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| logical_device_role_name_no_parent | 1 | logical | name | parent is NULL | Matches on fields: name where parent is NULL | ≥4.3.0 | +| logical_device_role_slug_no_parent | 2 | logical | slug | parent is NULL | Matches on fields: slug where parent is NULL | ≥4.3.0 | +| unique_name | 3 | builtin | name | N/A | Matches on unique field(s): name | All versions | +| unique_slug | 4 | builtin | slug | N/A | Matches on unique field(s): slug | All versions | +| unique_autoslug_slug | 5 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## dcim.devicetype + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| dcim_devicetype_unique_manufacturer_model | 1 | builtin | manufacturer, model | N/A | Matches on unique constraint fields: manufacturer, model | All versions | +| dcim_devicetype_unique_manufacturer_slug | 2 | builtin | manufacturer, slug | N/A | Matches on unique constraint fields: manufacturer, slug | All versions | +| unique_autoslug_slug | 3 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## dcim.frontport + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| dcim_frontport_unique_device_name | 1 | builtin | device, name | N/A | Matches on unique constraint fields: device, name | All versions | +| dcim_frontport_unique_rear_port_position | 2 | builtin | rear_port, rear_port_position | N/A | Matches on unique constraint fields: rear_port, rear_port_position | All versions | + +## dcim.frontporttemplate + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| dcim_frontporttemplate_unique_device_type_name | 1 | builtin | device_type, name | N/A | Matches on unique constraint fields: device_type, name | All versions | +| dcim_frontporttemplate_unique_module_type_name | 2 | builtin | module_type, name | N/A | Matches on unique constraint fields: module_type, name | All versions | +| dcim_frontporttemplate_unique_rear_port_position | 3 | builtin | rear_port, rear_port_position | N/A | Matches on unique constraint fields: rear_port, rear_port_position | All versions | + +## dcim.interface + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_primary_mac_address | 1 | builtin | primary_mac_address | N/A | Matches on unique field(s): primary_mac_address | All versions | +| dcim_interface_unique_device_name | 2 | builtin | device, name | N/A | Matches on unique constraint fields: device, name | All versions | + +## dcim.interfacetemplate + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| dcim_interfacetemplate_unique_device_type_name | 1 | builtin | device_type, name | N/A | Matches on unique constraint fields: device_type, name | All versions | +| dcim_interfacetemplate_unique_module_type_name | 2 | builtin | module_type, name | N/A | Matches on unique constraint fields: module_type, name | All versions | + +## dcim.inventoryitem + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| logical_inventory_item_name_on_device_no_parent | 1 | logical | name, device | parent is NULL | Matches on fields: name, device where parent is NULL | All versions | +| unique_asset_tag | 2 | builtin | asset_tag | N/A | Matches on unique field(s): asset_tag | All versions | +| dcim_inventoryitem_unique_device_parent_name | 3 | builtin | device, parent, name | N/A | Matches on unique constraint fields: device, parent, name | All versions | + +## dcim.inventoryitemrole + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | +| unique_slug | 2 | builtin | slug | N/A | Matches on unique field(s): slug | All versions | +| unique_autoslug_slug | 3 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## dcim.inventoryitemtemplate + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| dcim_inventoryitemtemplate_unique_device_type_parent_name | 1 | builtin | device_type, parent, name | N/A | Matches on unique constraint fields: device_type, parent, name | All versions | + +## dcim.location + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| dcim_location_parent_name | 1 | builtin | site, parent, name | N/A | Matches on unique constraint fields: site, parent, name | All versions | +| dcim_location_name | 2 | builtin | site, name | parent is NULL | Matches on unique constraint fields: site, name where parent is NULL | All versions | +| dcim_location_parent_slug | 3 | builtin | site, parent, slug | N/A | Matches on unique constraint fields: site, parent, slug | All versions | +| dcim_location_slug | 4 | builtin | site, slug | parent is NULL | Matches on unique constraint fields: site, slug where parent is NULL | All versions | +| unique_autoslug_slug | 5 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## dcim.macaddress + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| logical_mac_address_within_parent | 1 | logical | mac_address, assigned_object_type, assigned_object_id | assigned_object_id is NOT NULL | Matches on fields: mac_address, assigned_object_type, assigned_object_id where assigned_object_id is NOT NULL | All versions | +| logical_mac_address_within_parent | 2 | logical | mac_address, assigned_object_type, assigned_object_id | assigned_object_id is NULL | Matches on fields: mac_address, assigned_object_type, assigned_object_id where assigned_object_id is NULL | All versions | + +## dcim.manufacturer + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | +| unique_slug | 2 | builtin | slug | N/A | Matches on unique field(s): slug | All versions | +| unique_autoslug_slug | 3 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## dcim.module + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_module_bay | 1 | builtin | module_bay | N/A | Matches on unique field(s): module_bay | All versions | +| unique_asset_tag | 2 | builtin | asset_tag | N/A | Matches on unique field(s): asset_tag | All versions | + +## dcim.modulebay + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| logical_module_bay_name_on_device | 1 | logical | name, device | N/A | Matches on fields: name, device | All versions | +| dcim_modulebay_unique_device_module_name | 2 | builtin | device, module, name | N/A | Matches on unique constraint fields: device, module, name | All versions | + +## dcim.modulebaytemplate + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| dcim_modulebaytemplate_unique_device_type_name | 1 | builtin | device_type, name | N/A | Matches on unique constraint fields: device_type, name | All versions | +| dcim_modulebaytemplate_unique_module_type_name | 2 | builtin | module_type, name | N/A | Matches on unique constraint fields: module_type, name | All versions | + +## dcim.moduletype + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| dcim_moduletype_unique_manufacturer_model | 1 | builtin | manufacturer, model | N/A | Matches on unique constraint fields: manufacturer, model | All versions | + +## dcim.platform + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | +| unique_slug | 2 | builtin | slug | N/A | Matches on unique field(s): slug | All versions | +| unique_autoslug_slug | 3 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## dcim.powerfeed + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| dcim_powerfeed_unique_power_panel_name | 1 | builtin | power_panel, name | N/A | Matches on unique constraint fields: power_panel, name | All versions | + +## dcim.poweroutlet + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| dcim_poweroutlet_unique_device_name | 1 | builtin | device, name | N/A | Matches on unique constraint fields: device, name | All versions | + +## dcim.poweroutlettemplate + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| dcim_poweroutlettemplate_unique_device_type_name | 1 | builtin | device_type, name | N/A | Matches on unique constraint fields: device_type, name | All versions | +| dcim_poweroutlettemplate_unique_module_type_name | 2 | builtin | module_type, name | N/A | Matches on unique constraint fields: module_type, name | All versions | + +## dcim.powerpanel + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| dcim_powerpanel_unique_site_name | 1 | builtin | site, name | N/A | Matches on unique constraint fields: site, name | All versions | + +## dcim.powerport + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| dcim_powerport_unique_device_name | 1 | builtin | device, name | N/A | Matches on unique constraint fields: device, name | All versions | + +## dcim.powerporttemplate + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| dcim_powerporttemplate_unique_device_type_name | 1 | builtin | device_type, name | N/A | Matches on unique constraint fields: device_type, name | All versions | +| dcim_powerporttemplate_unique_module_type_name | 2 | builtin | module_type, name | N/A | Matches on unique constraint fields: module_type, name | All versions | + +## dcim.rack + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_asset_tag | 1 | builtin | asset_tag | N/A | Matches on unique field(s): asset_tag | All versions | +| dcim_rack_unique_location_name | 2 | builtin | location, name | N/A | Matches on unique constraint fields: location, name | All versions | +| dcim_rack_unique_location_facility_id | 3 | builtin | location, facility_id | N/A | Matches on unique constraint fields: location, facility_id | All versions | + +## dcim.rackrole + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | +| unique_slug | 2 | builtin | slug | N/A | Matches on unique field(s): slug | All versions | +| unique_autoslug_slug | 3 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## dcim.racktype + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_slug | 1 | builtin | slug | N/A | Matches on unique field(s): slug | All versions | +| dcim_racktype_unique_manufacturer_model | 2 | builtin | manufacturer, model | N/A | Matches on unique constraint fields: manufacturer, model | All versions | +| dcim_racktype_unique_manufacturer_slug | 3 | builtin | manufacturer, slug | N/A | Matches on unique constraint fields: manufacturer, slug | All versions | +| unique_autoslug_slug | 4 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## dcim.rearport + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| dcim_rearport_unique_device_name | 1 | builtin | device, name | N/A | Matches on unique constraint fields: device, name | All versions | + +## dcim.rearporttemplate + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| dcim_rearporttemplate_unique_device_type_name | 1 | builtin | device_type, name | N/A | Matches on unique constraint fields: device_type, name | All versions | +| dcim_rearporttemplate_unique_module_type_name | 2 | builtin | module_type, name | N/A | Matches on unique constraint fields: module_type, name | All versions | + +## dcim.region + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| dcim_region_parent_name | 1 | builtin | parent, name | N/A | Matches on unique constraint fields: parent, name | All versions | +| dcim_region_name | 2 | builtin | name | parent is NULL | Matches on unique constraint fields: name where parent is NULL | All versions | +| dcim_region_parent_slug | 3 | builtin | parent, slug | N/A | Matches on unique constraint fields: parent, slug | All versions | +| dcim_region_slug | 4 | builtin | slug | parent is NULL | Matches on unique constraint fields: slug where parent is NULL | All versions | +| unique_autoslug_slug | 5 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## dcim.site + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | +| unique_slug | 2 | builtin | slug | N/A | Matches on unique field(s): slug | All versions | +| unique_autoslug_slug | 3 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## dcim.sitegroup + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| dcim_sitegroup_parent_name | 1 | builtin | parent, name | N/A | Matches on unique constraint fields: parent, name | All versions | +| dcim_sitegroup_name | 2 | builtin | name | parent is NULL | Matches on unique constraint fields: name where parent is NULL | All versions | +| dcim_sitegroup_parent_slug | 3 | builtin | parent, slug | N/A | Matches on unique constraint fields: parent, slug | All versions | +| dcim_sitegroup_slug | 4 | builtin | slug | parent is NULL | Matches on unique constraint fields: slug where parent is NULL | All versions | +| unique_autoslug_slug | 5 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## dcim.virtualchassis + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_master | 1 | builtin | master | N/A | Matches on unique field(s): master | All versions | + +## dcim.virtualdevicecontext + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_primary_ip4 | 1 | builtin | primary_ip4 | N/A | Matches on unique field(s): primary_ip4 | All versions | +| unique_primary_ip6 | 2 | builtin | primary_ip6 | N/A | Matches on unique field(s): primary_ip6 | All versions | +| dcim_virtualdevicecontext_device_identifier | 3 | builtin | device, identifier | N/A | Matches on unique constraint fields: device, identifier | All versions | +| dcim_virtualdevicecontext_device_name | 4 | builtin | device, name | N/A | Matches on unique constraint fields: device, name | All versions | + +## extras.bookmark + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| extras_bookmark_unique_per_object_and_user | 1 | builtin | object_type, object_id, user | N/A | Matches on unique constraint fields: object_type, object_id, user | All versions | + +## extras.configcontext + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | + +## extras.customfield + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | + +## extras.customfieldchoiceset + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | + +## extras.customlink + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | + +## extras.eventrule + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | + +## extras.notificationgroup + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | + +## extras.savedfilter + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | +| unique_slug | 2 | builtin | slug | N/A | Matches on unique field(s): slug | All versions | +| unique_autoslug_slug | 3 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## extras.script + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| extras_script_unique_name_module | 1 | builtin | name, module | N/A | Matches on unique constraint fields: name, module | All versions | + +## extras.tag + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | +| unique_slug | 2 | builtin | slug | N/A | Matches on unique field(s): slug | All versions | +| unique_autoslug_slug | 3 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## extras.webhook + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | + +## ipam.aggregate + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| logical_aggregate_prefix_no_rir | 1 | logical | prefix | rir is NULL | Matches on fields: prefix where rir is NULL | All versions | +| logical_aggregate_prefix_within_rir | 2 | logical | prefix, rir | rir is NOT NULL | Matches on fields: prefix, rir where rir is NOT NULL | All versions | + +## ipam.asn + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_asn | 1 | builtin | asn | N/A | Matches on unique field(s): asn | All versions | + +## ipam.asnrange + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | +| unique_slug | 2 | builtin | slug | N/A | Matches on unique field(s): slug | All versions | +| unique_autoslug_slug | 3 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## ipam.fhrpgroup + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| logical_fhrp_group_id | 1 | logical | group_id | N/A | Matches on fields: group_id | All versions | + +## ipam.fhrpgroupassignment + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| ipam_fhrpgroupassignment_unique_interface_group | 1 | builtin | interface_type, interface_id, group | N/A | Matches on unique constraint fields: interface_type, interface_id, group | All versions | + +## ipam.ipaddress + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| logical_ip_address_global_no_vrf | 1 | logical | | N/A | Matches IP address address in global namespace (no VRF) | All versions | +| logical_ip_address_within_vrf | 2 | logical | | N/A | Matches IP address address within VRF | All versions | + +## ipam.iprange + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| logical_ip_range_start_end_global_no_vrf | 1 | logical | | N/A | Matches IP range start_address, end_address within VRF context | All versions | +| logical_ip_range_start_end_within_vrf | 2 | logical | | N/A | Matches IP range start_address, end_address within VRF context | All versions | + +## ipam.prefix + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| logical_prefix_global_no_vrf | 1 | logical | prefix | vrf is NULL | Matches on fields: prefix where vrf is NULL | All versions | +| logical_prefix_within_vrf | 2 | logical | prefix, vrf | vrf is NOT NULL | Matches on fields: prefix, vrf where vrf is NOT NULL | All versions | + +## ipam.rir + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | +| unique_slug | 2 | builtin | slug | N/A | Matches on unique field(s): slug | All versions | +| unique_autoslug_slug | 3 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## ipam.role + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | +| unique_slug | 2 | builtin | slug | N/A | Matches on unique field(s): slug | All versions | +| unique_autoslug_slug | 3 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## ipam.routetarget + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | + +## ipam.service + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| logical_service_name_no_device_or_vm | 1 | logical | name | device is NULL AND virtual_machine is NULL | Matches on fields: name where device is NULL AND virtual_machine is NULL | ≤4.2.99 | +| logical_service_name_on_device | 2 | logical | name, device | device is NOT NULL | Matches on fields: name, device where device is NOT NULL | ≤4.2.99 | +| logical_service_name_on_vm | 3 | logical | name, virtual_machine | virtual_machine is NOT NULL | Matches on fields: name, virtual_machine where virtual_machine is NOT NULL | ≤4.2.99 | +| logical_service_name_on_parent | 4 | logical | name, parent_object_type, parent_object_id | parent_object_type is NOT NULL | Matches on fields: name, parent_object_type, parent_object_id where parent_object_type is NOT NULL | ≥4.3.0 | + +## ipam.servicetemplate + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | + +## ipam.vlan + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| logical_vlan_vid_no_group_or_svlan | 1 | logical | vid | group is NULL AND qinq_svlan is NULL | Matches on fields: vid where group is NULL AND qinq_svlan is NULL | All versions | +| ipam_vlan_unique_group_vid | 2 | builtin | group, vid | N/A | Matches on unique constraint fields: group, vid | All versions | +| ipam_vlan_unique_group_name | 3 | builtin | group, name | N/A | Matches on unique constraint fields: group, name | All versions | +| ipam_vlan_unique_qinq_svlan_vid | 4 | builtin | qinq_svlan, vid | N/A | Matches on unique constraint fields: qinq_svlan, vid | All versions | +| ipam_vlan_unique_qinq_svlan_name | 5 | builtin | qinq_svlan, name | N/A | Matches on unique constraint fields: qinq_svlan, name | All versions | + +## ipam.vlangroup + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| logical_vlan_group_name_no_scope | 1 | logical | name | scope_type is NULL | Matches on fields: name where scope_type is NULL | All versions | +| ipam_vlangroup_unique_scope_name | 2 | builtin | scope_type, scope_id, name | N/A | Matches on unique constraint fields: scope_type, scope_id, name | All versions | +| ipam_vlangroup_unique_scope_slug | 3 | builtin | scope_type, scope_id, slug | N/A | Matches on unique constraint fields: scope_type, scope_id, slug | All versions | +| unique_autoslug_slug | 4 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## ipam.vlantranslationpolicy + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | + +## ipam.vlantranslationrule + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| ipam_vlantranslationrule_unique_policy_local_vid | 1 | builtin | policy, local_vid | N/A | Matches on unique constraint fields: policy, local_vid | All versions | +| ipam_vlantranslationrule_unique_policy_remote_vid | 2 | builtin | policy, remote_vid | N/A | Matches on unique constraint fields: policy, remote_vid | All versions | + +## ipam.vrf + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_rd | 1 | builtin | rd | N/A | Matches on unique field(s): rd | All versions | + +## tenancy.contact + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| logical_contact_name | 1 | logical | name | N/A | Matches on fields: name | ≥4.3.0 | +| tenancy_contact_unique_group_name | 2 | builtin | group, name | N/A | Matches on unique constraint fields: group, name | All versions | + +## tenancy.contactassignment + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| tenancy_contactassignment_unique_object_contact_role | 1 | builtin | object_type, object_id, contact, role | N/A | Matches on unique constraint fields: object_type, object_id, contact, role | All versions | + +## tenancy.contactgroup + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| tenancy_contactgroup_unique_parent_name | 1 | builtin | parent, name | N/A | Matches on unique constraint fields: parent, name | All versions | +| unique_autoslug_slug | 2 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## tenancy.contactrole + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | +| unique_slug | 2 | builtin | slug | N/A | Matches on unique field(s): slug | All versions | +| unique_autoslug_slug | 3 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## tenancy.tenant + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| tenancy_tenant_unique_group_name | 1 | builtin | group, name | N/A | Matches on unique constraint fields: group, name | All versions | +| tenancy_tenant_unique_name | 2 | builtin | name | group is NULL | Matches on unique constraint fields: name where group is NULL | All versions | +| tenancy_tenant_unique_group_slug | 3 | builtin | group, slug | N/A | Matches on unique constraint fields: group, slug | All versions | +| tenancy_tenant_unique_slug | 4 | builtin | slug | group is NULL | Matches on unique constraint fields: slug where group is NULL | All versions | +| unique_autoslug_slug | 5 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## tenancy.tenantgroup + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | +| unique_slug | 2 | builtin | slug | N/A | Matches on unique field(s): slug | All versions | +| unique_autoslug_slug | 3 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## virtualization.cluster + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| logical_cluster_within_scope | 1 | logical | name, scope_type, scope_id | scope_type is NOT NULL | Matches on fields: name, scope_type, scope_id where scope_type is NOT NULL | All versions | +| logical_cluster_with_no_scope_or_group | 2 | logical | name | group is NULL AND scope_type is NULL | Matches on fields: name where group is NULL AND scope_type is NULL | All versions | +| virtualization_cluster_unique_group_name | 3 | builtin | group, name | N/A | Matches on unique constraint fields: group, name | All versions | +| virtualization_cluster_unique__site_name | 4 | builtin | _site, name | N/A | Matches on unique constraint fields: _site, name | All versions | + +## virtualization.clustergroup + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | +| unique_slug | 2 | builtin | slug | N/A | Matches on unique field(s): slug | All versions | +| unique_autoslug_slug | 3 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## virtualization.clustertype + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | +| unique_slug | 2 | builtin | slug | N/A | Matches on unique field(s): slug | All versions | +| unique_autoslug_slug | 3 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## virtualization.virtualdisk + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| virtualization_virtualdisk_unique_virtual_machine_name | 1 | builtin | virtual_machine, name | N/A | Matches on unique constraint fields: virtual_machine, name | All versions | + +## virtualization.virtualmachine + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| logical_virtual_machine_name_no_cluster | 1 | logical | name | cluster is NULL | Matches on fields: name where cluster is NULL | All versions | +| unique_primary_ip4 | 2 | builtin | primary_ip4 | N/A | Matches on unique field(s): primary_ip4 | All versions | +| unique_primary_ip6 | 3 | builtin | primary_ip6 | N/A | Matches on unique field(s): primary_ip6 | All versions | +| virtualization_virtualmachine_unique_name_cluster_tenant | 4 | builtin | | N/A | Custom matcher | All versions | +| virtualization_virtualmachine_unique_name_cluster | 5 | builtin | | tenant is NULL | Custom matcher | All versions | + +## virtualization.vminterface + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_primary_mac_address | 1 | builtin | primary_mac_address | N/A | Matches on unique field(s): primary_mac_address | All versions | +| virtualization_vminterface_unique_virtual_machine_name | 2 | builtin | virtual_machine, name | N/A | Matches on unique constraint fields: virtual_machine, name | All versions | + +## vpn.ikepolicy + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | + +## vpn.ikeproposal + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | + +## vpn.ipsecpolicy + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | + +## vpn.ipsecprofile + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | + +## vpn.ipsecproposal + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | + +## vpn.l2vpn + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | +| unique_slug | 2 | builtin | slug | N/A | Matches on unique field(s): slug | All versions | +| unique_autoslug_slug | 3 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## vpn.l2vpntermination + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| vpn_l2vpntermination_assigned_object | 1 | builtin | assigned_object_type, assigned_object_id | N/A | Matches on unique constraint fields: assigned_object_type, assigned_object_id | All versions | + +## vpn.tunnel + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | +| vpn_tunnel_group_name | 2 | builtin | group, name | N/A | Matches on unique constraint fields: group, name | All versions | +| vpn_tunnel_name | 3 | builtin | name | group is NULL | Matches on unique constraint fields: name where group is NULL | All versions | + +## vpn.tunnelgroup + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | +| unique_slug | 2 | builtin | slug | N/A | Matches on unique field(s): slug | All versions | +| unique_autoslug_slug | 3 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## vpn.tunneltermination + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| vpn_tunneltermination_termination | 1 | builtin | termination_type, termination_id | N/A | Matches on unique constraint fields: termination_type, termination_id | All versions | + +## wireless.wirelesslan + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| logical_wireless_lan_ssid_no_group_or_vlan | 1 | logical | ssid | group is NULL AND vlan is NULL | Matches on fields: ssid where group is NULL AND vlan is NULL | All versions | +| logical_wireless_lan_ssid_in_group | 2 | logical | ssid, group | group is NOT NULL | Matches on fields: ssid, group where group is NOT NULL | All versions | +| logical_wireless_lan_ssid_in_vlan | 3 | logical | ssid, vlan | vlan is NOT NULL | Matches on fields: ssid, vlan where vlan is NOT NULL | All versions | + +## wireless.wirelesslangroup + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| unique_name | 1 | builtin | name | N/A | Matches on unique field(s): name | All versions | +| unique_slug | 2 | builtin | slug | N/A | Matches on unique field(s): slug | All versions | +| wireless_wirelesslangroup_unique_parent_name | 3 | builtin | parent, name | N/A | Matches on unique constraint fields: parent, name | All versions | +| unique_autoslug_slug | 4 | builtin | slug | N/A | Matches on auto-generated slug field: slug | All versions | + +## wireless.wirelesslink + +| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints | +|--------------|------------|------|--------|-----------|-------------|---------------------| +| wireless_wirelesslink_unique_interfaces | 1 | builtin | interface_a, interface_b | N/A | Matches on unique constraint fields: interface_a, interface_b | All versions | diff --git a/netbox_diode_plugin/management/__init__.py b/netbox_diode_plugin/management/__init__.py new file mode 100644 index 0000000..dc8a3fb --- /dev/null +++ b/netbox_diode_plugin/management/__init__.py @@ -0,0 +1 @@ +"""Django management package for netbox_diode_plugin.""" diff --git a/netbox_diode_plugin/management/commands/__init__.py b/netbox_diode_plugin/management/commands/__init__.py new file mode 100644 index 0000000..9e9d6ea --- /dev/null +++ b/netbox_diode_plugin/management/commands/__init__.py @@ -0,0 +1 @@ +"""Django management commands for netbox_diode_plugin.""" diff --git a/netbox_diode_plugin/management/commands/generate_matching_docs.py b/netbox_diode_plugin/management/commands/generate_matching_docs.py new file mode 100644 index 0000000..5a00fcd --- /dev/null +++ b/netbox_diode_plugin/management/commands/generate_matching_docs.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python +"""Django management command to generate markdown documentation for NetBox Diode Plugin matching criteria.""" + +from dataclasses import dataclass +from typing import Optional + +from django.core.management.base import BaseCommand + +from netbox_diode_plugin.api.differ import SUPPORTED_MODELS +from netbox_diode_plugin.api.matcher import _LOGICAL_MATCHERS, get_model_matchers + + +@dataclass +class MatcherInfo: + """Information about a matcher for documentation.""" + + name: str + fields: list[str] | None = None + condition: str | None = None + description: str | None = None + matcher_type: str = "ObjectMatchCriteria" + version_constraints: str | None = None + matcher_source: str = "logical" # "logical" or "builtin" + + +class Command(BaseCommand): + """Django management command to generate markdown documentation for NetBox Diode Plugin matching criteria.""" + + help = "Generate markdown documentation for NetBox Diode Plugin matching criteria" + + def extract_condition_description(self, condition) -> str: + """Extract a human-readable description of a Q condition.""" + if condition is None: + return "None" + + # Handle simple conditions + if hasattr(condition, 'children'): + conditions = [] + for child in condition.children: + if isinstance(child, tuple): + field, value = child + if field.endswith('__isnull'): + field_name = field[:-8] + if value: + conditions.append(f"{field_name} is NULL") + else: + conditions.append(f"{field_name} is NOT NULL") + else: + conditions.append(f"{field} = {value}") + else: + conditions.append(str(child)) + + connector = " AND " if condition.connector == "AND" else " OR " + return connector.join(conditions) + + return str(condition) + + def get_matcher_description(self, matcher) -> str: # noqa: C901 + """Generate a human-readable description of what the matcher does.""" + # Handle IP Network matchers + if hasattr(matcher, 'ip_fields') and matcher.ip_fields and hasattr(matcher, 'vrf_field') and matcher.vrf_field: + ip_fields_str = ", ".join(matcher.ip_fields) + if matcher.name.startswith('logical_ip_address_global_no_vrf'): + return f"Matches IP address {ip_fields_str} in global namespace (no VRF)" + if matcher.name.startswith('logical_ip_address_within_vrf'): + return f"Matches IP address {ip_fields_str} within VRF" + if matcher.name.startswith('logical_ip_range'): + return f"Matches IP range {ip_fields_str} within VRF context" + + # Handle CustomFieldMatcher + if hasattr(matcher, 'custom_field') and matcher.custom_field: + return f"Matches on unique custom field: {matcher.custom_field}" + + # Handle AutoSlugMatcher + if hasattr(matcher, 'slug_field') and matcher.slug_field: + return f"Matches on auto-generated slug field: {matcher.slug_field}" + + # Handle builtin unique field matchers + if matcher.name.startswith('unique_') and hasattr(matcher, 'fields') and matcher.fields: + field_name = matcher.fields[0] if len(matcher.fields) == 1 else ", ".join(matcher.fields) + if matcher.name.startswith('unique_'): + return f"Matches on unique field(s): {field_name}" + + # Handle builtin UniqueConstraint matchers + if hasattr(matcher, 'fields') and matcher.fields and not matcher.name.startswith('logical_'): + fields_str = ", ".join(matcher.fields) + if hasattr(matcher, 'condition') and matcher.condition: + condition_desc = self.extract_condition_description(matcher.condition) + return f"Matches on unique constraint fields: {fields_str} where {condition_desc}" + return f"Matches on unique constraint fields: {fields_str}" + + # Standard field-based matcher + if hasattr(matcher, 'fields') and matcher.fields: + fields_str = ", ".join(matcher.fields) + if hasattr(matcher, 'condition') and matcher.condition: + condition_desc = self.extract_condition_description(matcher.condition) + return f"Matches on fields: {fields_str} where {condition_desc}" + return f"Matches on fields: {fields_str}" + + return "Custom matcher" + + def get_version_constraints(self, matcher) -> str | None: + """Get version constraints as a string.""" + constraints = [] + if hasattr(matcher, 'min_version') and matcher.min_version: + constraints.append(f"≥{matcher.min_version}") + if hasattr(matcher, 'max_version') and matcher.max_version: + constraints.append(f"≤{matcher.max_version}") + + return " ".join(constraints) if constraints else None + + def analyze_logical_matchers(self) -> dict[str, list[MatcherInfo]]: + """Analyze the logical matchers and extract documentation information.""" + documentation = {} + + for object_type, matcher_factory in _LOGICAL_MATCHERS.items(): + matchers = matcher_factory() + matcher_infos = [] + + for matcher in matchers: + info = MatcherInfo( + name=matcher.name, + fields=list(matcher.fields) if hasattr(matcher, 'fields') and matcher.fields else None, + condition=self.extract_condition_description(matcher.condition) if hasattr(matcher, 'condition') else None, + description=self.get_matcher_description(matcher), + matcher_type=matcher.__class__.__name__, + version_constraints=self.get_version_constraints(matcher), + matcher_source="logical" + ) + matcher_infos.append(info) + + documentation[object_type] = matcher_infos + + return documentation + + def analyze_builtin_matchers(self) -> dict[str, list[MatcherInfo]]: + """Analyze the builtin matchers and extract documentation information.""" + documentation = {} + + for object_type, model_info in SUPPORTED_MODELS.items(): + model_class = model_info["model"] + matchers = get_model_matchers(model_class) + matcher_infos = [] + + for matcher in matchers: + # Skip logical matchers as they're already handled + if matcher.name.startswith('logical_'): + continue + + # Extract fields for builtin matchers + fields = None + if hasattr(matcher, 'fields') and matcher.fields: + fields = list(matcher.fields) + elif hasattr(matcher, 'custom_field'): + fields = [f"custom_fields.{matcher.custom_field}"] + elif hasattr(matcher, 'slug_field'): + fields = [matcher.slug_field] + + info = MatcherInfo( + name=matcher.name, + fields=fields, + condition=self.extract_condition_description(matcher.condition) if hasattr(matcher, 'condition') else None, + description=self.get_matcher_description(matcher), + matcher_type=matcher.__class__.__name__, + version_constraints=self.get_version_constraints(matcher), + matcher_source="builtin" + ) + matcher_infos.append(info) + + if matcher_infos: # Only add if there are builtin matchers + documentation[object_type] = matcher_infos + + return documentation + + def combine_matchers( + self, + logical_docs: dict[str, list[MatcherInfo]], + builtin_docs: dict[str, list[MatcherInfo]], + ) -> dict[str, list[MatcherInfo]]: + """Combine logical and builtin matchers into a single documentation structure.""" + combined = {} + + # Get all object types + all_object_types = set(logical_docs.keys()) | set(builtin_docs.keys()) + + for object_type in all_object_types: + matchers = [] + + # Add logical matchers + if object_type in logical_docs: + matchers.extend(logical_docs[object_type]) + + # Add builtin matchers + if object_type in builtin_docs: + matchers.extend(builtin_docs[object_type]) + + if matchers: + combined[object_type] = matchers + + return combined + + def generate_markdown_table(self, docs: dict[str, list[MatcherInfo]]) -> str: + """Generate a markdown table from the documentation.""" + markdown = [] + markdown.append("# NetBox Diode Plugin - Object Matching Criteria") + markdown.append("") + markdown.append( + "This document describes how the Diode NetBox Plugin matches existing objects when applying changes. " + "The matchers will be applied in the order of their precedence, unttil one of them matches." + ) + markdown.append("") + markdown.append("## Matcher Types") + markdown.append("") + markdown.append("- **Logical Matchers**: Custom matching criteria that represent likely user intent") + markdown.append( + "- **Builtin Matchers**: Automatically generated from NetBox model constraints " + "(unique fields, unique constraints, custom fields, auto-slugs)" + ) + markdown.append("") + + # Sort object types for consistent output + sorted_object_types = sorted(docs.keys()) + + for object_type in sorted_object_types: + matchers = docs[object_type] + + markdown.append(f"## {object_type}") + markdown.append("") + + if not matchers: + markdown.append("No specific matching criteria defined.") + markdown.append("") + continue + + # Create table header + markdown.append("| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints |") + markdown.append("|--------------|---------------------|------|--------|-----------|-------------|---------------------|") + + for precedence, matcher in enumerate(matchers, start=1): + # Escape pipe characters in table cells + name = matcher.name.replace("|", "\\|") if matcher.name else "N/A" + matcher_type = matcher.matcher_source.replace("|", "\\|") + fields_str = ", ".join(matcher.fields).replace("|", "\\|") if matcher.fields else "" + condition_str = matcher.condition.replace("|", "\\|") if matcher.condition and matcher.condition != "None" else "N/A" + description = matcher.description.replace("|", "\\|") if matcher.description else "N/A" + version_str = matcher.version_constraints.replace("|", "\\|") if matcher.version_constraints else "All versions" + + markdown.append( + f"| {name} | {precedence} | {matcher_type} | {fields_str} | {condition_str} | {description} | {version_str} |" + ) + + markdown.append("") + + return "\n".join(markdown) + + def handle(self, *args, **options): + """Handle the command execution.""" + self.stdout.write("Analyzing logical matching criteria...") + logical_docs = self.analyze_logical_matchers() + + self.stdout.write("Analyzing builtin matching criteria...") + builtin_docs = self.analyze_builtin_matchers() + + self.stdout.write("Combining matchers...") + combined_docs = self.combine_matchers(logical_docs, builtin_docs) + + self.stdout.write("Generating markdown documentation...") + markdown_content = self.generate_markdown_table(combined_docs) + self.stdout.write(markdown_content) diff --git a/netbox_diode_plugin/tests/test_generate_matching_docs.py b/netbox_diode_plugin/tests/test_generate_matching_docs.py new file mode 100644 index 0000000..0c03dce --- /dev/null +++ b/netbox_diode_plugin/tests/test_generate_matching_docs.py @@ -0,0 +1,629 @@ +#!/usr/bin/env python +# Copyright 2025 NetBox Labs, Inc. +"""Diode NetBox Plugin - Tests for generate_matching_docs command.""" + +import io +import sys +from unittest import mock +from unittest.mock import patch + +from django.core.management import call_command +from django.core.management.base import CommandError +from django.db.models import Q +from django.test import TestCase + +from netbox_diode_plugin.api.matcher import AutoSlugMatcher, ObjectMatchCriteria +from netbox_diode_plugin.management.commands.generate_matching_docs import ( + Command, + MatcherInfo, +) + + +class MatcherInfoTestCase(TestCase): + """Test case for MatcherInfo dataclass.""" + + def test_matcher_info_creation(self): + """Test creating MatcherInfo with all fields.""" + info = MatcherInfo( + name="test_matcher", + fields=["field1", "field2"], + condition="field1 is NOT NULL", + description="Test matcher description", + matcher_type="ObjectMatchCriteria", + version_constraints="≥4.3.0" + ) + + self.assertEqual(info.name, "test_matcher") + self.assertEqual(info.fields, ["field1", "field2"]) + self.assertEqual(info.condition, "field1 is NOT NULL") + self.assertEqual(info.description, "Test matcher description") + self.assertEqual(info.matcher_type, "ObjectMatchCriteria") + self.assertEqual(info.version_constraints, "≥4.3.0") + + def test_matcher_info_defaults(self): + """Test creating MatcherInfo with default values.""" + info = MatcherInfo(name="test_matcher") + + self.assertEqual(info.name, "test_matcher") + self.assertIsNone(info.fields) + self.assertIsNone(info.condition) + self.assertIsNone(info.description) + self.assertEqual(info.matcher_type, "ObjectMatchCriteria") + self.assertIsNone(info.version_constraints) + + +class GenerateMatchingDocsCommandTestCase(TestCase): + """Test case for the generate_matching_docs command.""" + + def setUp(self): + """Set up the test case.""" + self.command = Command() + self.command.stdout = io.StringIO() + self.command.stderr = io.StringIO() + + def test_add_arguments(self): + """Test that the command accepts the --output argument.""" + from django.core.management import get_commands + from django.core.management.base import BaseCommand + + # Verify the command is registered + commands = get_commands() + self.assertIn('generate_matching_docs', commands) + + def test_extract_condition_description_none(self): + """Test extracting condition description from None.""" + result = self.command.extract_condition_description(None) + self.assertEqual(result, "None") + + def test_extract_condition_description_simple(self): + """Test extracting condition description from a simple condition.""" + condition = Q(field1__isnull=True) + result = self.command.extract_condition_description(condition) + self.assertEqual(result, "field1 is NULL") + + def test_extract_condition_description_complex(self): + """Test extracting condition description from a complex condition.""" + condition = Q(field1__isnull=True) & Q(field2="value") + result = self.command.extract_condition_description(condition) + self.assertEqual(result, "field1 is NULL AND field2 = value") + + def test_extract_condition_description_or(self): + """Test extracting condition description with OR connector.""" + condition = Q(field1="value1") | Q(field2="value2") + result = self.command.extract_condition_description(condition) + self.assertEqual(result, "field1 = value1 OR field2 = value2") + + def test_extract_condition_description_not_null(self): + """Test extracting condition description for NOT NULL.""" + condition = Q(field1__isnull=False) + result = self.command.extract_condition_description(condition) + self.assertEqual(result, "field1 is NOT NULL") + + def test_get_matcher_description_ip_address_global(self): + """Test getting matcher description for IP address global matcher.""" + mock_matcher = mock.MagicMock() + mock_matcher.name = "logical_ip_address_global_no_vrf" + mock_matcher.ip_fields = ["address"] + mock_matcher.vrf_field = "vrf" + + result = self.command.get_matcher_description(mock_matcher) + self.assertEqual(result, "Matches IP address address in global namespace (no VRF)") + + def test_get_matcher_description_ip_address_vrf(self): + """Test getting matcher description for IP address VRF matcher.""" + mock_matcher = mock.MagicMock() + mock_matcher.name = "logical_ip_address_within_vrf" + mock_matcher.ip_fields = ["address"] + mock_matcher.vrf_field = "vrf" + + result = self.command.get_matcher_description(mock_matcher) + self.assertEqual(result, "Matches IP address address within VRF") + + def test_get_matcher_description_ip_range(self): + """Test getting matcher description for IP range matcher.""" + mock_matcher = mock.MagicMock() + mock_matcher.name = "logical_ip_range_start_end_within_vrf" + mock_matcher.ip_fields = ["start_address", "end_address"] + mock_matcher.vrf_field = "vrf" + + result = self.command.get_matcher_description(mock_matcher) + self.assertEqual(result, "Matches IP range start_address, end_address within VRF context") + + def test_get_matcher_description_standard_fields(self): + """Test getting matcher description for standard field-based matcher.""" + matcher = ObjectMatchCriteria( + fields=["name", "site"], + name="test_matcher", + model_class=None, + condition=None + ) + + result = self.command.get_matcher_description(matcher) + self.assertEqual(result, "Matches on unique constraint fields: name, site") + + def test_get_matcher_description_with_condition(self): + """Test getting matcher description for matcher with condition.""" + matcher = ObjectMatchCriteria( + fields=["name", "site"], + name="test_matcher", + model_class=None, + condition=Q(site__isnull=False) + ) + + result = self.command.get_matcher_description(matcher) + self.assertEqual(result, "Matches on unique constraint fields: name, site where site is NOT NULL") + + def test_get_matcher_description_custom(self): + """Test getting matcher description for custom matcher.""" + matcher = ObjectMatchCriteria( + fields=None, + name="test_matcher", + model_class=None, + condition=None + ) + + result = self.command.get_matcher_description(matcher) + self.assertEqual(result, "Custom matcher") + + def test_get_version_constraints_none(self): + """Test getting version constraints when none are set.""" + mock_matcher = mock.MagicMock() + mock_matcher.min_version = None + mock_matcher.max_version = None + + result = self.command.get_version_constraints(mock_matcher) + self.assertIsNone(result) + + def test_get_version_constraints_min_only(self): + """Test getting version constraints with only min_version.""" + mock_matcher = mock.MagicMock() + mock_matcher.min_version = "4.3.0" + mock_matcher.max_version = None + + result = self.command.get_version_constraints(mock_matcher) + self.assertEqual(result, "≥4.3.0") + + def test_get_version_constraints_max_only(self): + """Test getting version constraints with only max_version.""" + mock_matcher = mock.MagicMock() + mock_matcher.min_version = None + mock_matcher.max_version = "4.2.99" + + result = self.command.get_version_constraints(mock_matcher) + self.assertEqual(result, "≤4.2.99") + + def test_get_version_constraints_both(self): + """Test getting version constraints with both min and max.""" + mock_matcher = mock.MagicMock() + mock_matcher.min_version = "4.3.0" + mock_matcher.max_version = "4.3.99" + + result = self.command.get_version_constraints(mock_matcher) + self.assertEqual(result, "≥4.3.0 ≤4.3.99") + + @mock.patch('netbox_diode_plugin.management.commands.generate_matching_docs._LOGICAL_MATCHERS') + def test_analyze_logical_matchers(self, mock_logical_matchers): + """Test analyzing logical matchers.""" + # Create mock matchers + mock_matcher1 = mock.MagicMock() + mock_matcher1.name = "test_matcher_1" + mock_matcher1.fields = ["name"] + mock_matcher1.condition = None + mock_matcher1.min_version = "4.3.0" + mock_matcher1.max_version = None + + mock_matcher2 = mock.MagicMock() + mock_matcher2.name = "test_matcher_2" + mock_matcher2.fields = ["name", "site"] + mock_matcher2.condition = Q(site__isnull=False) + mock_matcher2.min_version = None + mock_matcher2.max_version = "4.2.99" + + # Mock the matcher factory + mock_logical_matchers.items.return_value = [ + ("dcim.site", lambda: [mock_matcher1, mock_matcher2]) + ] + + result = self.command.analyze_logical_matchers() + + self.assertIn("dcim.site", result) + self.assertEqual(len(result["dcim.site"]), 2) + + # Check first matcher + matcher1_info = result["dcim.site"][0] + self.assertEqual(matcher1_info.name, "test_matcher_1") + self.assertEqual(matcher1_info.fields, ["name"]) + self.assertEqual(matcher1_info.condition, "None") + self.assertEqual(matcher1_info.version_constraints, "≥4.3.0") + self.assertEqual(matcher1_info.matcher_source, "logical") + + # Check second matcher + matcher2_info = result["dcim.site"][1] + self.assertEqual(matcher2_info.name, "test_matcher_2") + self.assertEqual(matcher2_info.fields, ["name", "site"]) + self.assertEqual(matcher2_info.condition, "site is NOT NULL") + self.assertEqual(matcher2_info.version_constraints, "≤4.2.99") + self.assertEqual(matcher2_info.matcher_source, "logical") + + @mock.patch('netbox_diode_plugin.management.commands.generate_matching_docs.SUPPORTED_MODELS') + @mock.patch('netbox_diode_plugin.management.commands.generate_matching_docs.get_model_matchers') + def test_analyze_builtin_matchers(self, mock_get_model_matchers, mock_supported_models): + """Test analyzing builtin matchers.""" + # Create mock model class + mock_model_class = mock.MagicMock() + mock_model_class.__name__ = "TestModel" + + # Create mock builtin matchers + mock_unique_matcher = mock.MagicMock() + mock_unique_matcher.name = "unique_name" + mock_unique_matcher.fields = ["name"] + mock_unique_matcher.condition = None + mock_unique_matcher.min_version = None + mock_unique_matcher.max_version = None + + mock_constraint_matcher = mock.MagicMock() + mock_constraint_matcher.name = "test_constraint" + mock_constraint_matcher.fields = ["field1", "field2"] + mock_constraint_matcher.condition = Q(field1__isnull=True) + mock_constraint_matcher.min_version = None + mock_constraint_matcher.max_version = None + + # Mock logical matcher (should be skipped) + mock_logical_matcher = mock.MagicMock() + mock_logical_matcher.name = "logical_test" + mock_logical_matcher.fields = ["name"] + mock_logical_matcher.condition = None + mock_logical_matcher.min_version = None + mock_logical_matcher.max_version = None + + # Mock the supported models and get_model_matchers + mock_supported_models.items.return_value = [ + ("dcim.site", {"model": mock_model_class}) + ] + mock_get_model_matchers.return_value = [ + mock_unique_matcher, + mock_constraint_matcher, + mock_logical_matcher # This should be skipped + ] + + result = self.command.analyze_builtin_matchers() + + self.assertIn("dcim.site", result) + self.assertEqual(len(result["dcim.site"]), 2) # Should only include builtin matchers + + # Check unique field matcher + unique_matcher_info = result["dcim.site"][0] + self.assertEqual(unique_matcher_info.name, "unique_name") + self.assertEqual(unique_matcher_info.fields, ["name"]) + self.assertEqual(unique_matcher_info.matcher_source, "builtin") + + # Check constraint matcher + constraint_matcher_info = result["dcim.site"][1] + self.assertEqual(constraint_matcher_info.name, "test_constraint") + self.assertEqual(constraint_matcher_info.fields, ["field1", "field2"]) + self.assertEqual(constraint_matcher_info.matcher_source, "builtin") + + def test_combine_matchers(self): + """Test combining logical and builtin matchers.""" + # Create logical matchers + logical_matcher = MatcherInfo( + name="logical_test", + fields=["name"], + condition="N/A", + description="Logical matcher", + version_constraints="All versions", + matcher_source="logical" + ) + + # Create builtin matchers + builtin_matcher = MatcherInfo( + name="builtin_test", + fields=["name"], + condition="N/A", + description="Builtin matcher", + version_constraints="All versions", + matcher_source="builtin" + ) + + logical_docs = { + "dcim.site": [logical_matcher], + "dcim.device": [logical_matcher] + } + + builtin_docs = { + "dcim.site": [builtin_matcher], + "ipam.prefix": [builtin_matcher] + } + + result = self.command.combine_matchers(logical_docs, builtin_docs) + + # Check that all object types are included + self.assertIn("dcim.site", result) + self.assertIn("dcim.device", result) + self.assertIn("ipam.prefix", result) + + # Check that dcim.site has both logical and builtin matchers + site_matchers = result["dcim.site"] + self.assertEqual(len(site_matchers), 2) + + # Check that logical matcher comes first (as it was added first) + self.assertEqual(site_matchers[0].name, "logical_test") + self.assertEqual(site_matchers[0].matcher_source, "logical") + self.assertEqual(site_matchers[1].name, "builtin_test") + self.assertEqual(site_matchers[1].matcher_source, "builtin") + + # Check that other object types have correct matchers + self.assertEqual(len(result["dcim.device"]), 1) + self.assertEqual(result["dcim.device"][0].matcher_source, "logical") + + self.assertEqual(len(result["ipam.prefix"]), 1) + self.assertEqual(result["ipam.prefix"][0].matcher_source, "builtin") + + def test_get_matcher_description_builtin_types(self): + """Test getting matcher description for different builtin matcher types.""" + # Test CustomFieldMatcher + mock_custom_field_matcher = mock.MagicMock() + mock_custom_field_matcher.custom_field = "test_field" + mock_custom_field_matcher.fields = None + mock_custom_field_matcher.ip_fields = None + mock_custom_field_matcher.vrf_field = None + + result = self.command.get_matcher_description(mock_custom_field_matcher) + self.assertEqual(result, "Matches on unique custom field: test_field") + + def test_get_matcher_description_autoslug(self): + """Test getting matcher description for AutoSlugMatcher.""" + # Test AutoSlugMatcher + autoslug_matcher = AutoSlugMatcher( + name="test_autoslug", + model_class=None, + slug_field="slug" + ) + + result = self.command.get_matcher_description(autoslug_matcher) + self.assertEqual(result, "Matches on auto-generated slug field: slug") + + def test_get_matcher_description_unique_field(self): + """Test getting matcher description for unique field matcher.""" + # Test unique field matcher + mock_unique_matcher = mock.MagicMock() + mock_unique_matcher.name = "unique_name" + mock_unique_matcher.fields = ["name"] + mock_unique_matcher.ip_fields = None + mock_unique_matcher.vrf_field = None + mock_unique_matcher.custom_field = None + mock_unique_matcher.slug_field = None + + result = self.command.get_matcher_description(mock_unique_matcher) + self.assertEqual(result, "Matches on unique field(s): name") + + def test_get_matcher_description_unique_constraint(self): + """Test getting matcher description for unique constraint matcher.""" + # Test unique constraint matcher + mock_constraint_matcher = mock.MagicMock() + mock_constraint_matcher.name = "test_constraint" + mock_constraint_matcher.fields = ["field1", "field2"] + mock_constraint_matcher.condition = None + mock_constraint_matcher.custom_field = None + mock_constraint_matcher.slug_field = None + + result = self.command.get_matcher_description(mock_constraint_matcher) + self.assertEqual(result, "Matches on unique constraint fields: field1, field2") + + def test_generate_markdown_table_empty(self): + """Test generating markdown table with empty documentation.""" + docs = {} + result = self.command.generate_markdown_table(docs) + + expected_lines = [ + "# NetBox Diode Plugin - Object Matching Criteria", + "", + "This document describes how the Diode NetBox Plugin matches existing objects when applying changes.", + "" + ] + + for line in expected_lines: + self.assertIn(line, result) + + def test_generate_markdown_table_with_matchers(self): + """Test generating markdown table with matchers.""" + matcher_info1 = MatcherInfo( + name="test_matcher_1", + fields=["name"], + condition="N/A", + description="Test description 1", + version_constraints="All versions", + matcher_source="logical" + ) + + matcher_info2 = MatcherInfo( + name="test_matcher_2", + fields=["name", "site"], + condition="site is NOT NULL", + description="Test description 2", + version_constraints="≥4.3.0", + matcher_source="logical" + ) + + docs = { + "dcim.site": [matcher_info1, matcher_info2] + } + + result = self.command.generate_markdown_table(docs) + + # Check header + self.assertIn("# NetBox Diode Plugin - Object Matching Criteria", result) + self.assertIn("## dcim.site", result) + + # Check table header + self.assertIn("| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints |", result) + self.assertIn("|--------------|---------------------|------|--------|-----------|-------------|---------------------|", result) + + # Check table rows + self.assertIn("| test_matcher_1 | 1 | logical | name | N/A | Test description 1 | All versions |", result) + self.assertIn("| test_matcher_2 | 2 | logical | name, site | site is NOT NULL | Test description 2 | ≥4.3.0 |", result) + + def test_generate_markdown_table_with_pipe_escaping(self): + """Test generating markdown table with pipe character escaping.""" + matcher_info = MatcherInfo( + name="test|matcher", + fields=["field|1", "field|2"], + condition="field|1 is NOT NULL", + description="Test|description", + version_constraints="≥4.3.0|test", + matcher_source="logical" + ) + + docs = { + "dcim.site": [matcher_info] + } + + result = self.command.generate_markdown_table(docs) + + # Check that pipe characters are escaped + self.assertIn( + "| test\\|matcher | 1 | logical | field\\|1, field\\|2 | field\\|1 is NOT NULL | Test\\|description | ≥4.3.0\\|test |", + result, + ) + + def test_generate_markdown_table_no_matchers(self): + """Test generating markdown table for object type with no matchers.""" + docs = { + "dcim.site": [] + } + + result = self.command.generate_markdown_table(docs) + + self.assertIn("## dcim.site", result) + self.assertIn("No specific matching criteria defined.", result) + + def test_generate_markdown_table_sorted_object_types(self): + """Test that object types are sorted in the output.""" + matcher_info = MatcherInfo( + name="test_matcher", + fields=["name"], + condition="N/A", + description="Test description", + version_constraints="All versions" + ) + + docs = { + "dcim.device": [matcher_info], + "dcim.site": [matcher_info], + "ipam.prefix": [matcher_info] + } + + result = self.command.generate_markdown_table(docs) + + # Check that sections appear in alphabetical order + site_index = result.find("## dcim.site") + device_index = result.find("## dcim.device") + prefix_index = result.find("## ipam.prefix") + + self.assertLess(device_index, site_index) + self.assertLess(site_index, prefix_index) + + def test_condition_extraction_with_none_Q_condition(self): + """Test edge cases in condition extraction.""" + # Test with non-Q condition + condition = "simple_string_condition" + result = self.command.extract_condition_description(condition) + self.assertEqual(result, "simple_string_condition") + + def test_condition_extraction_with_Q_condition_with_no_children(self): + """Test with Q condition that has no children.""" + condition = Q() + result = self.command.extract_condition_description(condition) + self.assertEqual(result, "") + + def test_condition_extraction_with_Q_condition_with_children(self): + """Test with complex nested condition.""" + condition = Q(field1__isnull=True) & (Q(field2="value1") | Q(field2="value2")) + result = self.command.extract_condition_description(condition) + self.assertEqual(result, "field1 is NULL AND (OR: ('field2', 'value1'), ('field2', 'value2'))") + + def test_matcher_description_edge_cases(self): + """Test edge cases in matcher description generation.""" + # Test with matcher that has fields but no condition + matcher = ObjectMatchCriteria( + fields=["name"], + name="test_matcher", + model_class=None, + condition=None + ) + + result = self.command.get_matcher_description(matcher) + self.assertEqual(result, "Matches on unique constraint fields: name") + + # Test with matcher that has condition but no fields + matcher = ObjectMatchCriteria( + fields=None, + name="test_matcher", + model_class=None, + condition=Q(field1__isnull=True) + ) + + with mock.patch.object(self.command, 'extract_condition_description') as mock_extract: + mock_extract.return_value = "field1 is NULL" + result = self.command.get_matcher_description(matcher) + + self.assertEqual(result, "Custom matcher") + + def test_version_constraints_edge_cases(self): + """Test edge cases in version constraints.""" + # Test with empty string versions + mock_matcher = mock.MagicMock() + mock_matcher.min_version = "" + mock_matcher.max_version = "" + + result = self.command.get_version_constraints(mock_matcher) + self.assertEqual(result, None) + + # Test with whitespace versions + mock_matcher = mock.MagicMock() + mock_matcher.min_version = " 4.3.0 " + mock_matcher.max_version = " 4.3.99 " + + result = self.command.get_version_constraints(mock_matcher) + self.assertEqual(result, "≥ 4.3.0 ≤ 4.3.99 ") + + def test_markdown_table_with_empty_values(self): + """Test edge cases in markdown table generation.""" + # Test with None values + matcher_info = MatcherInfo( + name="test_matcher", + fields=None, + condition=None, + description=None, + version_constraints=None, + matcher_source="logical" + ) + + docs = { + "dcim.site": [matcher_info] + } + + result = self.command.generate_markdown_table(docs) + + # Check that None values are handled gracefully + self.assertIn("| test_matcher | 1 | logical | | N/A | N/A | All versions |", result) + + def test_markdown_table_with_empty_fields(self): + """Test with empty fields list.""" + matcher_info = MatcherInfo( + name="test_matcher", + fields=[], + condition="N/A", + description="Test description", + version_constraints="All versions", + matcher_source="logical" + ) + + docs = { + "dcim.site": [matcher_info] + } + + result = self.command.generate_markdown_table(docs) + + # Check that empty fields list is handled + self.assertIn("| test_matcher | 1 | logical | | N/A | Test description | All versions |", result) From f7cc7f49501360694ec20a40c6097dac111f7b5c Mon Sep 17 00:00:00 2001 From: James Jeffries Date: Mon, 14 Jul 2025 12:16:32 +0100 Subject: [PATCH 3/7] chore: linting action improvements (#120) * fails the lint/test action if either tests or linting fail * wip: force a test failure * wip: force a lint failure * fixes tests and linting * ignore linting for complex method --- .github/workflows/lint-tests.yml | 10 ++++++++++ Makefile | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/.github/workflows/lint-tests.yml b/.github/workflows/lint-tests.yml index 8370855..5bdc379 100644 --- a/.github/workflows/lint-tests.yml +++ b/.github/workflows/lint-tests.yml @@ -40,12 +40,22 @@ jobs: pip install .[dev] pip install .[test] - name: Lint with Ruff + id: lint run: | ruff check --output-format=github netbox_diode_plugin/ continue-on-error: true - name: Test + id: test run: | make docker-compose-netbox-plugin-test-cover + continue-on-error: true + - name: Check results + if: always() + run: | + if [[ "${{ steps.lint.outcome }}" == "failure" || "${{ steps.test.outcome }}" == "failure" ]]; then + echo "Either linting or tests failed" + exit 1 + fi - name: Coverage comment uses: orgoro/coverage@3f13a558c5af7376496aa4848bf0224aead366ac # v3.2 if: github.event.pull_request.head.repo.full_name == github.repository diff --git a/Makefile b/Makefile index d6642ab..fa292d3 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,11 @@ docker-compose-netbox-plugin-test: -@$(DOCKER_COMPOSE) -f docker/docker-compose.yaml -f docker/docker-compose.test.yaml run -u root --rm netbox ./manage.py test $(TEST_FLAGS) --keepdb netbox_diode_plugin @$(MAKE) docker-compose-netbox-plugin-down +.PHONY: docker-compose-netbox-plugin-test-lint +docker-compose-netbox-plugin-test-lint: + -@$(DOCKER_COMPOSE) -f docker/docker-compose.yaml -f docker/docker-compose.test.yaml run -u root --rm netbox ruff check --output-format=github netbox_diode_plugin + @$(MAKE) docker-compose-netbox-plugin-down + .PHONY: docker-compose-netbox-plugin-test-cover docker-compose-netbox-plugin-test-cover: -@$(DOCKER_COMPOSE) -f docker/docker-compose.yaml -f docker/docker-compose.test.yaml run --rm -u root -e COVERAGE_FILE=/opt/netbox/netbox/coverage/.coverage netbox sh -c "coverage run --source=netbox_diode_plugin --omit=*/migrations/* ./manage.py test --keepdb netbox_diode_plugin && coverage xml -o /opt/netbox/netbox/coverage/report.xml && coverage report -m | tee /opt/netbox/netbox/coverage/report.txt" From 3d4c27eaa892eb87c6e965962a1f7b765160c474 Mon Sep 17 00:00:00 2001 From: Luke Tucker <64618+ltucker@users.noreply.github.com> Date: Wed, 23 Jul 2025 11:07:31 -0400 Subject: [PATCH 4/7] feat: support additional v4.3 types and fields (#122) * feat: support additional v4.3 models and fields, compatibility updates * run tests with current and v4.2.3 NetBox --- .github/workflows/lint-tests.yml | 5 +- .gitignore | 1 + Makefile | 23 +- docker/Dockerfile-diode-netbox-plugin | 5 +- docker/docker-compose.yaml | 2 +- docker/requirements-diode-netbox-plugin.txt | 2 +- docker/v4.2.3/Dockerfile-diode-netbox-plugin | 12 + docker/v4.2.3/docker-compose.test.yaml | 5 + docker/v4.2.3/docker-compose.yaml | 92 + .../netbox/configuration/configuration.py | 327 + docker/v4.2.3/netbox/configuration/extra.py | 49 + .../v4.2.3/netbox/configuration/ldap/extra.py | 28 + .../netbox/configuration/ldap/ldap_config.py | 113 + docker/v4.2.3/netbox/configuration/logging.py | 72 + docker/v4.2.3/netbox/configuration/plugins.py | 29 + docker/v4.2.3/netbox/docker-entrypoint.sh | 100 + docker/v4.2.3/netbox/env/netbox.env | 41 + docker/v4.2.3/netbox/env/postgres.env | 3 + docker/v4.2.3/netbox/env/redis-cache.env | 1 + docker/v4.2.3/netbox/env/redis.env | 1 + docker/v4.2.3/netbox/launch-netbox.sh | 75 + docker/v4.2.3/netbox/local_settings.py | 13 + docker/v4.2.3/netbox/nginx-unit.json | 65 + docker/v4.2.3/netbox/plugins_dev.py | 20 + docker/v4.2.3/netbox/plugins_test.py | 16 + .../requirements-diode-netbox-plugin.txt | 7 + netbox_diode_plugin/api/common.py | 9 +- netbox_diode_plugin/api/compat.py | 17 + netbox_diode_plugin/api/differ.py | 46 +- netbox_diode_plugin/api/matcher.py | 54 +- netbox_diode_plugin/api/plugin_utils.py | 341 +- netbox_diode_plugin/api/supported_models.py | 262 +- netbox_diode_plugin/api/transformer.py | 52 +- netbox_diode_plugin/api/views.py | 55 +- .../commands/generate_matching_docs.py | 4 +- .../tests/test_api_apply_change_set.py | 190 +- .../tests/test_api_diff_and_apply.py | 1 - .../tests/test_generate_matching_docs.py | 8 +- .../tests/test_updates_cases.json | 203 +- .../tests/v4.2.3/tests/__init__.py | 3 + .../v4.2.3/tests/test_api_apply_change_set.py | 932 +++ .../v4.2.3/tests/test_api_diff_and_apply.py | 1497 ++++ .../v4.2.3/tests/test_api_generate_diff.py | 421 ++ .../tests/v4.2.3/tests/test_authentication.py | 188 + .../tests/v4.2.3/tests/test_diode_clients.py | 232 + .../tests/v4.2.3/tests/test_forms.py | 51 + .../tests/test_generate_matching_docs.py | 629 ++ .../tests/v4.2.3/tests/test_models.py | 30 + .../tests/v4.2.3/tests/test_plugin_config.py | 26 + .../tests/v4.2.3/tests/test_updates.py | 195 + .../v4.2.3/tests/test_updates_cases.json | 6106 +++++++++++++++++ .../tests/v4.2.3/tests/test_version.py | 19 + .../tests/v4.2.3/tests/test_views.py | 248 + 53 files changed, 12395 insertions(+), 531 deletions(-) create mode 100644 docker/v4.2.3/Dockerfile-diode-netbox-plugin create mode 100644 docker/v4.2.3/docker-compose.test.yaml create mode 100644 docker/v4.2.3/docker-compose.yaml create mode 100644 docker/v4.2.3/netbox/configuration/configuration.py create mode 100644 docker/v4.2.3/netbox/configuration/extra.py create mode 100644 docker/v4.2.3/netbox/configuration/ldap/extra.py create mode 100644 docker/v4.2.3/netbox/configuration/ldap/ldap_config.py create mode 100644 docker/v4.2.3/netbox/configuration/logging.py create mode 100644 docker/v4.2.3/netbox/configuration/plugins.py create mode 100755 docker/v4.2.3/netbox/docker-entrypoint.sh create mode 100644 docker/v4.2.3/netbox/env/netbox.env create mode 100644 docker/v4.2.3/netbox/env/postgres.env create mode 100644 docker/v4.2.3/netbox/env/redis-cache.env create mode 100644 docker/v4.2.3/netbox/env/redis.env create mode 100755 docker/v4.2.3/netbox/launch-netbox.sh create mode 100644 docker/v4.2.3/netbox/local_settings.py create mode 100644 docker/v4.2.3/netbox/nginx-unit.json create mode 100644 docker/v4.2.3/netbox/plugins_dev.py create mode 100644 docker/v4.2.3/netbox/plugins_test.py create mode 100644 docker/v4.2.3/requirements-diode-netbox-plugin.txt create mode 100644 netbox_diode_plugin/tests/v4.2.3/tests/__init__.py create mode 100644 netbox_diode_plugin/tests/v4.2.3/tests/test_api_apply_change_set.py create mode 100644 netbox_diode_plugin/tests/v4.2.3/tests/test_api_diff_and_apply.py create mode 100644 netbox_diode_plugin/tests/v4.2.3/tests/test_api_generate_diff.py create mode 100644 netbox_diode_plugin/tests/v4.2.3/tests/test_authentication.py create mode 100644 netbox_diode_plugin/tests/v4.2.3/tests/test_diode_clients.py create mode 100644 netbox_diode_plugin/tests/v4.2.3/tests/test_forms.py create mode 100644 netbox_diode_plugin/tests/v4.2.3/tests/test_generate_matching_docs.py create mode 100644 netbox_diode_plugin/tests/v4.2.3/tests/test_models.py create mode 100644 netbox_diode_plugin/tests/v4.2.3/tests/test_plugin_config.py create mode 100644 netbox_diode_plugin/tests/v4.2.3/tests/test_updates.py create mode 100644 netbox_diode_plugin/tests/v4.2.3/tests/test_updates_cases.json create mode 100644 netbox_diode_plugin/tests/v4.2.3/tests/test_version.py create mode 100644 netbox_diode_plugin/tests/v4.2.3/tests/test_views.py diff --git a/.github/workflows/lint-tests.yml b/.github/workflows/lint-tests.yml index 5bdc379..baf1239 100644 --- a/.github/workflows/lint-tests.yml +++ b/.github/workflows/lint-tests.yml @@ -26,6 +26,7 @@ jobs: strategy: matrix: python: [ "3.10" ] + netbox: [ "", "v4.2.3" ] steps: - name: Checkout uses: actions/checkout@v4 @@ -47,7 +48,7 @@ jobs: - name: Test id: test run: | - make docker-compose-netbox-plugin-test-cover + make NETBOX_VERSION=${{ matrix.netbox }} docker-compose-netbox-plugin-test-cover continue-on-error: true - name: Check results if: always() @@ -58,7 +59,7 @@ jobs: fi - name: Coverage comment uses: orgoro/coverage@3f13a558c5af7376496aa4848bf0224aead366ac # v3.2 - if: github.event.pull_request.head.repo.full_name == github.repository + if: github.event.pull_request.head.repo.full_name == github.repository && matrix.netbox == '' with: coverageFile: ./docker/coverage/report.xml token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 53cce25..877a41d 100644 --- a/.gitignore +++ b/.gitignore @@ -29,5 +29,6 @@ dist/ # Docker docker/coverage !docker/netbox/env +!docker/*/netbox/env docker/oauth2/secrets/* !docker/oauth2/secrets/.gitkeep diff --git a/Makefile b/Makefile index fa292d3..b45c181 100644 --- a/Makefile +++ b/Makefile @@ -4,33 +4,42 @@ else DOCKER_COMPOSE := docker-compose endif +NETBOX_VERSION ?= +ifneq ($(NETBOX_VERSION),) + DOCKER_PATH := docker/$(NETBOX_VERSION) + TEST_SELECTOR := "/opt/netbox/netbox/netbox_diode_plugin/tests/$(NETBOX_VERSION)/tests/" +else + DOCKER_PATH := docker + TEST_SELECTOR = netbox_diode_plugin +endif + .PHONY: docker-compose-netbox-plugin-up docker-compose-netbox-plugin-up: - @$(DOCKER_COMPOSE) -f docker/docker-compose.yaml up -d --build + @$(DOCKER_COMPOSE) -f $(DOCKER_PATH)/docker-compose.yaml up -d --build .PHONY: docker-compose-netbox-plugin-down docker-compose-netbox-plugin-down: - @$(DOCKER_COMPOSE) -f docker/docker-compose.yaml down + @$(DOCKER_COMPOSE) -f $(DOCKER_PATH)/docker-compose.yaml down .PHONY: docker-compose-netbox-plugin-test docker-compose-netbox-plugin-test: - -@$(DOCKER_COMPOSE) -f docker/docker-compose.yaml -f docker/docker-compose.test.yaml run -u root --rm netbox ./manage.py test $(TEST_FLAGS) --keepdb netbox_diode_plugin + -@$(DOCKER_COMPOSE) -f $(DOCKER_PATH)/docker-compose.yaml -f $(DOCKER_PATH)/docker-compose.test.yaml run -u root --rm netbox ./manage.py test $(TEST_FLAGS) --keepdb $(TEST_SELECTOR) @$(MAKE) docker-compose-netbox-plugin-down .PHONY: docker-compose-netbox-plugin-test-lint docker-compose-netbox-plugin-test-lint: - -@$(DOCKER_COMPOSE) -f docker/docker-compose.yaml -f docker/docker-compose.test.yaml run -u root --rm netbox ruff check --output-format=github netbox_diode_plugin + -@$(DOCKER_COMPOSE) -f $(DOCKER_PATH)/docker-compose.yaml -f $(DOCKER_PATH)/docker-compose.test.yaml run -u root --rm netbox ruff check --output-format=github netbox_diode_plugin @$(MAKE) docker-compose-netbox-plugin-down .PHONY: docker-compose-netbox-plugin-test-cover docker-compose-netbox-plugin-test-cover: - -@$(DOCKER_COMPOSE) -f docker/docker-compose.yaml -f docker/docker-compose.test.yaml run --rm -u root -e COVERAGE_FILE=/opt/netbox/netbox/coverage/.coverage netbox sh -c "coverage run --source=netbox_diode_plugin --omit=*/migrations/* ./manage.py test --keepdb netbox_diode_plugin && coverage xml -o /opt/netbox/netbox/coverage/report.xml && coverage report -m | tee /opt/netbox/netbox/coverage/report.txt" + -@$(DOCKER_COMPOSE) -f $(DOCKER_PATH)/docker-compose.yaml -f $(DOCKER_PATH)/docker-compose.test.yaml run --rm -u root -e COVERAGE_FILE=/opt/netbox/netbox/coverage/.coverage netbox sh -c "coverage run --source=netbox_diode_plugin --omit=*/migrations/* ./manage.py test --keepdb $(TEST_SELECTOR) && coverage xml -o /opt/netbox/netbox/coverage/report.xml && coverage report -m | tee /opt/netbox/netbox/coverage/report.txt" @$(MAKE) docker-compose-netbox-plugin-down .PHONY: docker-compose-generate-matching-docs docker-compose-generate-matching-docs: - @$(DOCKER_COMPOSE) -f docker/docker-compose.yaml -f docker/docker-compose.test.yaml run --rm netbox python manage.py generate_matching_docs | awk '/Generating markdown documentation.../{p=1;next} p' > ./docs/matching-criteria-documentation.md + @$(DOCKER_COMPOSE) -f $(DOCKER_PATH)/docker-compose.yaml -f $(DOCKER_PATH)/docker-compose.test.yaml run --rm netbox python manage.py generate_matching_docs | awk '/Generating markdown documentation.../{p=1;next} p' > ./docs/matching-criteria-documentation.md .PHONY: docker-compose-migrate docker-compose-migrate: - @$(DOCKER_COMPOSE) -f docker/docker-compose.yaml -f docker/docker-compose.test.yaml run --rm netbox python manage.py migrate + @$(DOCKER_COMPOSE) -f $(DOCKER_PATH)/docker-compose.yaml -f $(DOCKER_PATH)/docker-compose.test.yaml run --rm netbox python manage.py migrate diff --git a/docker/Dockerfile-diode-netbox-plugin b/docker/Dockerfile-diode-netbox-plugin index 24a73fd..c488e63 100644 --- a/docker/Dockerfile-diode-netbox-plugin +++ b/docker/Dockerfile-diode-netbox-plugin @@ -1,4 +1,4 @@ -FROM netboxcommunity/netbox:v4.2.3-3.1.1 +FROM netboxcommunity/netbox:v4.3.3-3.3.0 COPY ./netbox/configuration/ /etc/netbox/config/ RUN chmod 755 /etc/netbox/config/* && \ @@ -9,4 +9,5 @@ RUN chmod 755 /opt/netbox/netbox/netbox/local_settings.py && \ chown unit:root /opt/netbox/netbox/netbox/local_settings.py COPY ./requirements-diode-netbox-plugin.txt /opt/netbox/ -RUN /opt/netbox/venv/bin/pip install --no-warn-script-location -r /opt/netbox/requirements-diode-netbox-plugin.txt +ENV VIRTUAL_ENV=/opt/netbox/venv +RUN /usr/local/bin/uv pip install -r /opt/netbox/requirements-diode-netbox-plugin.txt diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 94677dd..7cea9c6 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -1,7 +1,7 @@ name: diode-netbox-plugin services: netbox: &netbox - image: netboxcommunity/netbox:v4.2.3-3.1.1-diode-netbox-plugin + image: netboxcommunity/netbox:v4.3.3-3.3.0-diode-netbox-plugin build: context: . dockerfile: Dockerfile-diode-netbox-plugin diff --git a/docker/requirements-diode-netbox-plugin.txt b/docker/requirements-diode-netbox-plugin.txt index 5ce20bc..3340d33 100644 --- a/docker/requirements-diode-netbox-plugin.txt +++ b/docker/requirements-diode-netbox-plugin.txt @@ -4,4 +4,4 @@ coverage==7.6.0 grpcio==1.62.1 protobuf==5.29.5 pytest==8.0.2 -netboxlabs-netbox-branching==0.5.7 \ No newline at end of file +netboxlabs-netbox-branching==0.6.0 \ No newline at end of file diff --git a/docker/v4.2.3/Dockerfile-diode-netbox-plugin b/docker/v4.2.3/Dockerfile-diode-netbox-plugin new file mode 100644 index 0000000..24a73fd --- /dev/null +++ b/docker/v4.2.3/Dockerfile-diode-netbox-plugin @@ -0,0 +1,12 @@ +FROM netboxcommunity/netbox:v4.2.3-3.1.1 + +COPY ./netbox/configuration/ /etc/netbox/config/ +RUN chmod 755 /etc/netbox/config/* && \ + chown unit:root /etc/netbox/config/* + +COPY ./netbox/local_settings.py /opt/netbox/netbox/netbox/local_settings.py +RUN chmod 755 /opt/netbox/netbox/netbox/local_settings.py && \ + chown unit:root /opt/netbox/netbox/netbox/local_settings.py + +COPY ./requirements-diode-netbox-plugin.txt /opt/netbox/ +RUN /opt/netbox/venv/bin/pip install --no-warn-script-location -r /opt/netbox/requirements-diode-netbox-plugin.txt diff --git a/docker/v4.2.3/docker-compose.test.yaml b/docker/v4.2.3/docker-compose.test.yaml new file mode 100644 index 0000000..e6d202f --- /dev/null +++ b/docker/v4.2.3/docker-compose.test.yaml @@ -0,0 +1,5 @@ +name: diode-netbox-plugin-4.2.3 +services: + netbox: + volumes: + - ./netbox/plugins_test.py:/etc/netbox/config/plugins.py:z,ro diff --git a/docker/v4.2.3/docker-compose.yaml b/docker/v4.2.3/docker-compose.yaml new file mode 100644 index 0000000..510f83a --- /dev/null +++ b/docker/v4.2.3/docker-compose.yaml @@ -0,0 +1,92 @@ +name: diode-netbox-plugin-4.2.3 +services: + netbox: &netbox + image: netboxcommunity/netbox:v4.2.3-3.1.1-diode-netbox-plugin + build: + context: . + dockerfile: Dockerfile-diode-netbox-plugin + pull: true + depends_on: + - netbox-postgres + - netbox-redis + - netbox-redis-cache + env_file: netbox/env/netbox.env + user: 'unit:root' + healthcheck: + start_period: 60s + timeout: 3s + interval: 15s + test: "curl -f http://localhost:8080/netbox/api/ || exit 1" + volumes: + - ./netbox/docker-entrypoint.sh:/opt/netbox/docker-entrypoint.sh:z,ro + - ./netbox/nginx-unit.json:/opt/netbox/nginx-unit.json:z,ro + - ../../netbox_diode_plugin:/opt/netbox/netbox/netbox_diode_plugin:z,rw + - ../oauth2/secrets:/run/secrets:z,ro + - ./netbox/launch-netbox.sh:/opt/netbox/launch-netbox.sh:z,ro + - ./netbox/plugins_dev.py:/etc/netbox/config/plugins.py:z,ro + - ./coverage:/opt/netbox/netbox/coverage:z,rw + - netbox-media-files:/opt/netbox/netbox/media:rw + - netbox-reports-files:/opt/netbox/netbox/reports:rw + - netbox-scripts-files:/opt/netbox/netbox/scripts:rw + extra_hosts: + - "host.docker.internal:host-gateway" + ports: + - "8000:8080" + + netbox-worker: + <<: *netbox + depends_on: + netbox: + condition: service_healthy + command: + - /opt/netbox/venv/bin/python + - /opt/netbox/netbox/manage.py + - rqworker + healthcheck: + test: ps -aux | grep -v grep | grep -q rqworker || exit 1 + start_period: 20s + timeout: 3s + interval: 15s + ports: [] + + # postgres + netbox-postgres: + image: docker.io/postgres:16-alpine + env_file: netbox/env/postgres.env + volumes: + - netbox-postgres-data:/var/lib/postgresql/data + + # redis + netbox-redis: + image: docker.io/redis:7-alpine + command: + - sh + - -c # this is to evaluate the $REDIS_PASSWORD from the env + - redis-server --appendonly yes --requirepass $$REDIS_PASSWORD ## $$ because of docker-compose + env_file: netbox/env/redis.env + volumes: + - netbox-redis-data:/data + + netbox-redis-cache: + image: docker.io/redis:7-alpine + command: + - sh + - -c # this is to evaluate the $REDIS_PASSWORD from the env + - redis-server --requirepass $$REDIS_PASSWORD ## $$ because of docker-compose + env_file: netbox/env/redis-cache.env + volumes: + - netbox-redis-cache-data:/data + +volumes: + netbox-media-files: + driver: local + netbox-postgres-data: + driver: local + netbox-redis-cache-data: + driver: local + netbox-redis-data: + driver: local + netbox-reports-files: + driver: local + netbox-scripts-files: + driver: local diff --git a/docker/v4.2.3/netbox/configuration/configuration.py b/docker/v4.2.3/netbox/configuration/configuration.py new file mode 100644 index 0000000..d459441 --- /dev/null +++ b/docker/v4.2.3/netbox/configuration/configuration.py @@ -0,0 +1,327 @@ +#### +## We recommend to not edit this file. +## Create separate files to overwrite the settings. +## See `extra.py` as an example. +#### + +import re +from os import environ +from os.path import abspath, dirname, join +from typing import Any, Callable + +# For reference see https://docs.netbox.dev/en/stable/configuration/ +# Based on https://github.com/netbox-community/netbox/blob/develop/netbox/netbox/configuration_example.py + +### +# NetBox-Docker Helper functions +### + +# Read secret from file +def _read_secret(secret_name: str, default: str | None = None) -> str | None: + try: + f = open('/run/secrets/' + secret_name, encoding='utf-8') + except OSError: + return default + else: + with f: + return f.readline().strip() + + +# If the `map_fn` isn't defined, then the value that is read from the environment (or the default value if not found) is returned. +# If the `map_fn` is defined, then `map_fn` is invoked and the value (that was read from the environment or the default value if not found) +# is passed to it as a parameter. The value returned from `map_fn` is then the return value of this function. +# The `map_fn` is not invoked, if the value (that was read from the environment or the default value if not found) is None. +def _environ_get_and_map(variable_name: str, default: str | None = None, + map_fn: Callable[[str], Any | None] = None) -> Any | None: + env_value = environ.get(variable_name, default) + + if env_value is None: + return env_value + + if not map_fn: + return env_value + + return map_fn(env_value) + + +def _AS_BOOL(value): + return value.lower() == 'true' +def _AS_INT(value): + return int(value) +def _AS_LIST(value): + return list(filter(None, value.split(' '))) + +_BASE_DIR = dirname(dirname(abspath(__file__))) + +######################### +# # +# Required settings # +# # +######################### + +# This is a list of valid fully-qualified domain names (FQDNs) for the NetBox server. NetBox will not permit write +# access to the server via any other hostnames. The first FQDN in the list will be treated as the preferred name. +# +# Example: ALLOWED_HOSTS = ['netbox.example.com', 'netbox.internal.local'] +ALLOWED_HOSTS = environ.get('ALLOWED_HOSTS', '*').split(' ') +# ensure that '*' or 'localhost' is always in ALLOWED_HOSTS (needed for health checks) +if '*' not in ALLOWED_HOSTS and 'localhost' not in ALLOWED_HOSTS: + ALLOWED_HOSTS.append('localhost') + +# PostgreSQL database configuration. See the Django documentation for a complete list of available parameters: +# https://docs.djangoproject.com/en/stable/ref/settings/#databases +DATABASE = { + 'NAME': environ.get('DB_NAME', 'netbox'), # Database name + 'USER': environ.get('DB_USER', ''), # PostgreSQL username + 'PASSWORD': _read_secret('db_password', environ.get('DB_PASSWORD', '')), + # PostgreSQL password + 'HOST': environ.get('DB_HOST', 'localhost'), # Database server + 'PORT': environ.get('DB_PORT', ''), # Database port (leave blank for default) + 'OPTIONS': {'sslmode': environ.get('DB_SSLMODE', 'prefer')}, + # Database connection SSLMODE + 'CONN_MAX_AGE': _environ_get_and_map('DB_CONN_MAX_AGE', '300', _AS_INT), + # Max database connection age + 'DISABLE_SERVER_SIDE_CURSORS': _environ_get_and_map('DB_DISABLE_SERVER_SIDE_CURSORS', 'False', _AS_BOOL), + # Disable the use of server-side cursors transaction pooling +} + +# Redis database settings. Redis is used for caching and for queuing background tasks such as webhook events. A separate +# configuration exists for each. Full connection details are required in both sections, and it is strongly recommended +# to use two separate database IDs. +REDIS = { + 'tasks': { + 'HOST': environ.get('REDIS_HOST', 'localhost'), + 'PORT': _environ_get_and_map('REDIS_PORT', 6379, _AS_INT), + 'USERNAME': environ.get('REDIS_USERNAME', ''), + 'PASSWORD': _read_secret('redis_password', environ.get('REDIS_PASSWORD', '')), + 'DATABASE': _environ_get_and_map('REDIS_DATABASE', 0, _AS_INT), + 'SSL': _environ_get_and_map('REDIS_SSL', 'False', _AS_BOOL), + 'INSECURE_SKIP_TLS_VERIFY': _environ_get_and_map('REDIS_INSECURE_SKIP_TLS_VERIFY', 'False', _AS_BOOL), + }, + 'caching': { + 'HOST': environ.get('REDIS_CACHE_HOST', environ.get('REDIS_HOST', 'localhost')), + 'PORT': _environ_get_and_map('REDIS_CACHE_PORT', environ.get('REDIS_PORT', '6379'), _AS_INT), + 'USERNAME': environ.get('REDIS_CACHE_USERNAME', environ.get('REDIS_USERNAME', '')), + 'PASSWORD': _read_secret('redis_cache_password', + environ.get('REDIS_CACHE_PASSWORD', environ.get('REDIS_PASSWORD', ''))), + 'DATABASE': _environ_get_and_map('REDIS_CACHE_DATABASE', '1', _AS_INT), + 'SSL': _environ_get_and_map('REDIS_CACHE_SSL', environ.get('REDIS_SSL', 'False'), _AS_BOOL), + 'INSECURE_SKIP_TLS_VERIFY': _environ_get_and_map('REDIS_CACHE_INSECURE_SKIP_TLS_VERIFY', + environ.get('REDIS_INSECURE_SKIP_TLS_VERIFY', 'False'), + _AS_BOOL), + }, +} + +# This key is used for secure generation of random numbers and strings. It must never be exposed outside of this file. +# For optimal security, SECRET_KEY should be at least 50 characters in length and contain a mix of letters, numbers, and +# symbols. NetBox will not run without this defined. For more information, see +# https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-SECRET_KEY +SECRET_KEY = _read_secret('secret_key', environ.get('SECRET_KEY', '')) + +######################### +# # +# Optional settings # +# # +######################### + +# # Specify one or more name and email address tuples representing NetBox administrators. These people will be notified of +# # application errors (assuming correct email settings are provided). +# ADMINS = [ +# # ['John Doe', 'jdoe@example.com'], +# ] + +if 'ALLOWED_URL_SCHEMES' in environ: + ALLOWED_URL_SCHEMES = _environ_get_and_map('ALLOWED_URL_SCHEMES', None, _AS_LIST) + +# Optionally display a persistent banner at the top and/or bottom of every page. HTML is allowed. To display the same +# content in both banners, define BANNER_TOP and set BANNER_BOTTOM = BANNER_TOP. +if 'BANNER_TOP' in environ: + BANNER_TOP = environ.get('BANNER_TOP', None) +if 'BANNER_BOTTOM' in environ: + BANNER_BOTTOM = environ.get('BANNER_BOTTOM', None) + +# Text to include on the login page above the login form. HTML is allowed. +if 'BANNER_LOGIN' in environ: + BANNER_LOGIN = environ.get('BANNER_LOGIN', None) + +# Maximum number of days to retain logged changes. Set to 0 to retain changes indefinitely. (Default: 90) +if 'CHANGELOG_RETENTION' in environ: + CHANGELOG_RETENTION = _environ_get_and_map('CHANGELOG_RETENTION', None, _AS_INT) + +# Maximum number of days to retain job results (scripts and reports). Set to 0 to retain job results in the database indefinitely. (Default: 90) +if 'JOB_RETENTION' in environ: + JOB_RETENTION = _environ_get_and_map('JOB_RETENTION', None, _AS_INT) +# JOBRESULT_RETENTION was renamed to JOB_RETENTION in the v3.5.0 release of NetBox. For backwards compatibility, map JOBRESULT_RETENTION to JOB_RETENTION +elif 'JOBRESULT_RETENTION' in environ: + JOB_RETENTION = _environ_get_and_map('JOBRESULT_RETENTION', None, _AS_INT) + +# API Cross-Origin Resource Sharing (CORS) settings. If CORS_ORIGIN_ALLOW_ALL is set to True, all origins will be +# allowed. Otherwise, define a list of allowed origins using either CORS_ORIGIN_WHITELIST or +# CORS_ORIGIN_REGEX_WHITELIST. For more information, see https://github.com/ottoyiu/django-cors-headers +CORS_ORIGIN_ALLOW_ALL = _environ_get_and_map('CORS_ORIGIN_ALLOW_ALL', 'False', _AS_BOOL) +CORS_ORIGIN_WHITELIST = _environ_get_and_map('CORS_ORIGIN_WHITELIST', 'https://localhost', _AS_LIST) +CORS_ORIGIN_REGEX_WHITELIST = [re.compile(r) for r in _environ_get_and_map('CORS_ORIGIN_REGEX_WHITELIST', '', _AS_LIST)] + +# Set to True to enable server debugging. WARNING: Debugging introduces a substantial performance penalty and may reveal +# sensitive information about your installation. Only enable debugging while performing testing. +# Never enable debugging on a production system. +DEBUG = _environ_get_and_map('DEBUG', 'False', _AS_BOOL) + +# This parameter serves as a safeguard to prevent some potentially dangerous behavior, +# such as generating new database schema migrations. +# Set this to True only if you are actively developing the NetBox code base. +DEVELOPER = _environ_get_and_map('DEVELOPER', 'False', _AS_BOOL) + +# Email settings +EMAIL = { + 'SERVER': environ.get('EMAIL_SERVER', 'localhost'), + 'PORT': _environ_get_and_map('EMAIL_PORT', 25, _AS_INT), + 'USERNAME': environ.get('EMAIL_USERNAME', ''), + 'PASSWORD': _read_secret('email_password', environ.get('EMAIL_PASSWORD', '')), + 'USE_SSL': _environ_get_and_map('EMAIL_USE_SSL', 'False', _AS_BOOL), + 'USE_TLS': _environ_get_and_map('EMAIL_USE_TLS', 'False', _AS_BOOL), + 'SSL_CERTFILE': environ.get('EMAIL_SSL_CERTFILE', ''), + 'SSL_KEYFILE': environ.get('EMAIL_SSL_KEYFILE', ''), + 'TIMEOUT': _environ_get_and_map('EMAIL_TIMEOUT', 10, _AS_INT), # seconds + 'FROM_EMAIL': environ.get('EMAIL_FROM', ''), +} + +# Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce unique IP space within the global table +# (all prefixes and IP addresses not assigned to a VRF), set ENFORCE_GLOBAL_UNIQUE to True. +if 'ENFORCE_GLOBAL_UNIQUE' in environ: + ENFORCE_GLOBAL_UNIQUE = _environ_get_and_map('ENFORCE_GLOBAL_UNIQUE', None, _AS_BOOL) + +# Exempt certain models from the enforcement of view permissions. Models listed here will be viewable by all users and +# by anonymous users. List models in the form `.`. Add '*' to this list to exempt all models. +EXEMPT_VIEW_PERMISSIONS = _environ_get_and_map('EXEMPT_VIEW_PERMISSIONS', '', _AS_LIST) + +# HTTP proxies NetBox should use when sending outbound HTTP requests (e.g. for webhooks). +# HTTP_PROXIES = { +# 'http': 'http://10.10.1.10:3128', +# 'https': 'http://10.10.1.10:1080', +# } + +# IP addresses recognized as internal to the system. The debugging toolbar will be available only to clients accessing +# NetBox from an internal IP. +INTERNAL_IPS = _environ_get_and_map('INTERNAL_IPS', '127.0.0.1 ::1', _AS_LIST) + +# Enable GraphQL API. +if 'GRAPHQL_ENABLED' in environ: + GRAPHQL_ENABLED = _environ_get_and_map('GRAPHQL_ENABLED', None, _AS_BOOL) + +# # Enable custom logging. Please see the Django documentation for detailed guidance on configuring custom logs: +# # https://docs.djangoproject.com/en/stable/topics/logging/ +# LOGGING = {} + +# Automatically reset the lifetime of a valid session upon each authenticated request. Enables users to remain +# authenticated to NetBox indefinitely. +LOGIN_PERSISTENCE = _environ_get_and_map('LOGIN_PERSISTENCE', 'False', _AS_BOOL) + +# Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users +# are permitted to access most data in NetBox (excluding secrets) but not make any changes. +LOGIN_REQUIRED = _environ_get_and_map('LOGIN_REQUIRED', 'False', _AS_BOOL) + +# The length of time (in seconds) for which a user will remain logged into the web UI before being prompted to +# re-authenticate. (Default: 1209600 [14 days]) +LOGIN_TIMEOUT = _environ_get_and_map('LOGIN_TIMEOUT', 1209600, _AS_INT) + +# Setting this to True will display a "maintenance mode" banner at the top of every page. +if 'MAINTENANCE_MODE' in environ: + MAINTENANCE_MODE = _environ_get_and_map('MAINTENANCE_MODE', None, _AS_BOOL) + +# Maps provider +if 'MAPS_URL' in environ: + MAPS_URL = environ.get('MAPS_URL', None) + +# An API consumer can request an arbitrary number of objects =by appending the "limit" parameter to the URL (e.g. +# "?limit=1000"). This setting defines the maximum limit. Setting it to 0 or None will allow an API consumer to request +# all objects by specifying "?limit=0". +if 'MAX_PAGE_SIZE' in environ: + MAX_PAGE_SIZE = _environ_get_and_map('MAX_PAGE_SIZE', None, _AS_INT) + +# The file path where uploaded media such as image attachments are stored. A trailing slash is not needed. Note that +# the default value of this setting is derived from the installed location. +MEDIA_ROOT = environ.get('MEDIA_ROOT', join(_BASE_DIR, 'media')) + +# Expose Prometheus monitoring metrics at the HTTP endpoint '/metrics' +METRICS_ENABLED = _environ_get_and_map('METRICS_ENABLED', 'False', _AS_BOOL) + +# Determine how many objects to display per page within a list. (Default: 50) +if 'PAGINATE_COUNT' in environ: + PAGINATE_COUNT = _environ_get_and_map('PAGINATE_COUNT', None, _AS_INT) + +# # Enable installed plugins. Add the name of each plugin to the list. +# PLUGINS = [] + +# # Plugins configuration settings. These settings are used by various plugins that the user may have installed. +# # Each key in the dictionary is the name of an installed plugin and its value is a dictionary of settings. +# PLUGINS_CONFIG = { +# } + +# When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to +# prefer IPv4 instead. +if 'PREFER_IPV4' in environ: + PREFER_IPV4 = _environ_get_and_map('PREFER_IPV4', None, _AS_BOOL) + +# The default value for the amperage field when creating new power feeds. +if 'POWERFEED_DEFAULT_AMPERAGE' in environ: + POWERFEED_DEFAULT_AMPERAGE = _environ_get_and_map('POWERFEED_DEFAULT_AMPERAGE', None, _AS_INT) + +# The default value (percentage) for the max_utilization field when creating new power feeds. +if 'POWERFEED_DEFAULT_MAX_UTILIZATION' in environ: + POWERFEED_DEFAULT_MAX_UTILIZATION = _environ_get_and_map('POWERFEED_DEFAULT_MAX_UTILIZATION', None, _AS_INT) + +# The default value for the voltage field when creating new power feeds. +if 'POWERFEED_DEFAULT_VOLTAGE' in environ: + POWERFEED_DEFAULT_VOLTAGE = _environ_get_and_map('POWERFEED_DEFAULT_VOLTAGE', None, _AS_INT) + +# Rack elevation size defaults, in pixels. For best results, the ratio of width to height should be roughly 10:1. +if 'RACK_ELEVATION_DEFAULT_UNIT_HEIGHT' in environ: + RACK_ELEVATION_DEFAULT_UNIT_HEIGHT = _environ_get_and_map('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', None, _AS_INT) +if 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH' in environ: + RACK_ELEVATION_DEFAULT_UNIT_WIDTH = _environ_get_and_map('RACK_ELEVATION_DEFAULT_UNIT_WIDTH', None, _AS_INT) + +# Remote authentication support +REMOTE_AUTH_ENABLED = _environ_get_and_map('REMOTE_AUTH_ENABLED', 'False', _AS_BOOL) +REMOTE_AUTH_BACKEND = _environ_get_and_map('REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend', _AS_LIST) +REMOTE_AUTH_HEADER = environ.get('REMOTE_AUTH_HEADER', 'HTTP_REMOTE_USER') +REMOTE_AUTH_AUTO_CREATE_USER = _environ_get_and_map('REMOTE_AUTH_AUTO_CREATE_USER', 'False', _AS_BOOL) +REMOTE_AUTH_DEFAULT_GROUPS = _environ_get_and_map('REMOTE_AUTH_DEFAULT_GROUPS', '', _AS_LIST) +# REMOTE_AUTH_DEFAULT_PERMISSIONS = {} + +# This repository is used to check whether there is a new release of NetBox available. Set to None to disable the +# version check or use the URL below to check for release in the official NetBox repository. +RELEASE_CHECK_URL = environ.get('RELEASE_CHECK_URL', None) +# RELEASE_CHECK_URL = 'https://api.github.com/repos/netbox-community/netbox/releases' + +# Maximum execution time for background tasks, in seconds. +RQ_DEFAULT_TIMEOUT = _environ_get_and_map('RQ_DEFAULT_TIMEOUT', 300, _AS_INT) + +# The name to use for the csrf token cookie. +CSRF_COOKIE_NAME = environ.get('CSRF_COOKIE_NAME', 'csrftoken') + +# Cross-Site-Request-Forgery-Attack settings. If Netbox is sitting behind a reverse proxy, you might need to set the CSRF_TRUSTED_ORIGINS flag. +# Django 4.0 requires to specify the URL Scheme in this setting. An example environment variable could be specified like: +# CSRF_TRUSTED_ORIGINS=https://demo.netbox.dev http://demo.netbox.dev +CSRF_TRUSTED_ORIGINS = _environ_get_and_map('CSRF_TRUSTED_ORIGINS', '', _AS_LIST) + +# The name to use for the session cookie. +SESSION_COOKIE_NAME = environ.get('SESSION_COOKIE_NAME', 'sessionid') + +# By default, NetBox will store session data in the database. Alternatively, a file path can be specified here to use +# local file storage instead. (This can be useful for enabling authentication on a standby instance with read-only +# database access.) Note that the user as which NetBox runs must have read and write permissions to this path. +SESSION_FILE_PATH = environ.get('SESSION_FILE_PATH', environ.get('SESSIONS_ROOT', None)) + +# Time zone (default: UTC) +TIME_ZONE = environ.get('TIME_ZONE', 'UTC') + +# Date/time formatting. See the following link for supported formats: +# https://docs.djangoproject.com/en/stable/ref/templates/builtins/#date +DATE_FORMAT = environ.get('DATE_FORMAT', 'N j, Y') +SHORT_DATE_FORMAT = environ.get('SHORT_DATE_FORMAT', 'Y-m-d') +TIME_FORMAT = environ.get('TIME_FORMAT', 'g:i a') +SHORT_TIME_FORMAT = environ.get('SHORT_TIME_FORMAT', 'H:i:s') +DATETIME_FORMAT = environ.get('DATETIME_FORMAT', 'N j, Y g:i a') +SHORT_DATETIME_FORMAT = environ.get('SHORT_DATETIME_FORMAT', 'Y-m-d H:i') +BASE_PATH = environ.get('BASE_PATH', '') diff --git a/docker/v4.2.3/netbox/configuration/extra.py b/docker/v4.2.3/netbox/configuration/extra.py new file mode 100644 index 0000000..8bd1337 --- /dev/null +++ b/docker/v4.2.3/netbox/configuration/extra.py @@ -0,0 +1,49 @@ +#### +## This file contains extra configuration options that can't be configured +## directly through environment variables. +#### + +## Specify one or more name and email address tuples representing NetBox administrators. These people will be notified of +## application errors (assuming correct email settings are provided). +# ADMINS = [ +# # ['John Doe', 'jdoe@example.com'], +# ] + + +## URL schemes that are allowed within links in NetBox +# ALLOWED_URL_SCHEMES = ( +# 'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp', +# ) + +## Enable installed plugins. Add the name of each plugin to the list. +# from netbox.configuration.configuration import PLUGINS +# PLUGINS.append('my_plugin') + +## Plugins configuration settings. These settings are used by various plugins that the user may have installed. +## Each key in the dictionary is the name of an installed plugin and its value is a dictionary of settings. +# from netbox.configuration.configuration import PLUGINS_CONFIG +# PLUGINS_CONFIG['my_plugin'] = { +# 'foo': 'bar', +# 'buzz': 'bazz' +# } + + +## Remote authentication support +# REMOTE_AUTH_DEFAULT_PERMISSIONS = {} + + +## By default uploaded media is stored on the local filesystem. Using Django-storages is also supported. Provide the +## class path of the storage driver in STORAGE_BACKEND and any configuration options in STORAGE_CONFIG. For example: +# STORAGE_BACKEND = 'storages.backends.s3boto3.S3Boto3Storage' +# STORAGE_CONFIG = { +# 'AWS_ACCESS_KEY_ID': 'Key ID', +# 'AWS_SECRET_ACCESS_KEY': 'Secret', +# 'AWS_STORAGE_BUCKET_NAME': 'netbox', +# 'AWS_S3_REGION_NAME': 'eu-west-1', +# } + + +## This file can contain arbitrary Python code, e.g.: +# from datetime import datetime +# now = datetime.now().strftime("%d/%m/%Y %H:%M:%S") +# BANNER_TOP = f'This instance started on {now}.' diff --git a/docker/v4.2.3/netbox/configuration/ldap/extra.py b/docker/v4.2.3/netbox/configuration/ldap/extra.py new file mode 100644 index 0000000..4505197 --- /dev/null +++ b/docker/v4.2.3/netbox/configuration/ldap/extra.py @@ -0,0 +1,28 @@ +#### +## This file contains extra configuration options that can't be configured +## directly through environment variables. +## All vairables set here overwrite any existing found in ldap_config.py +#### + +# # This Python script inherits all the imports from ldap_config.py +# from django_auth_ldap.config import LDAPGroupQuery # Imported since not in ldap_config.py + +# # Sets a base requirement of membetship to netbox-user-ro, netbox-user-rw, or netbox-user-admin. +# AUTH_LDAP_REQUIRE_GROUP = ( +# LDAPGroupQuery("cn=netbox-user-ro,ou=groups,dc=example,dc=com") +# | LDAPGroupQuery("cn=netbox-user-rw,ou=groups,dc=example,dc=com") +# | LDAPGroupQuery("cn=netbox-user-admin,ou=groups,dc=example,dc=com") +# ) + +# # Sets LDAP Flag groups variables with example. +# AUTH_LDAP_USER_FLAGS_BY_GROUP = { +# "is_staff": ( +# LDAPGroupQuery("cn=netbox-user-ro,ou=groups,dc=example,dc=com") +# | LDAPGroupQuery("cn=netbox-user-rw,ou=groups,dc=example,dc=com") +# | LDAPGroupQuery("cn=netbox-user-admin,ou=groups,dc=example,dc=com") +# ), +# "is_superuser": "cn=netbox-user-admin,ou=groups,dc=example,dc=com", +# } + +# # Sets LDAP Mirror groups variables with example groups +# AUTH_LDAP_MIRROR_GROUPS = ["netbox-user-ro", "netbox-user-rw", "netbox-user-admin"] diff --git a/docker/v4.2.3/netbox/configuration/ldap/ldap_config.py b/docker/v4.2.3/netbox/configuration/ldap/ldap_config.py new file mode 100644 index 0000000..32743c7 --- /dev/null +++ b/docker/v4.2.3/netbox/configuration/ldap/ldap_config.py @@ -0,0 +1,113 @@ +from importlib import import_module +from os import environ + +import ldap +from django_auth_ldap.config import LDAPSearch + + +# Read secret from file +def _read_secret(secret_name, default=None): + try: + f = open('/run/secrets/' + secret_name, encoding='utf-8') + except OSError: + return default + else: + with f: + return f.readline().strip() + + +# Import and return the group type based on string name +def _import_group_type(group_type_name): + mod = import_module('django_auth_ldap.config') + try: + return getattr(mod, group_type_name)() + except: + return None + + +# Server URI +AUTH_LDAP_SERVER_URI = environ.get('AUTH_LDAP_SERVER_URI', '') + +# The following may be needed if you are binding to Active Directory. +AUTH_LDAP_CONNECTION_OPTIONS = { + ldap.OPT_REFERRALS: 0 +} + +AUTH_LDAP_BIND_AS_AUTHENTICATING_USER = environ.get('AUTH_LDAP_BIND_AS_AUTHENTICATING_USER', 'False').lower() == 'true' + +# Set the DN and password for the NetBox service account if needed. +if not AUTH_LDAP_BIND_AS_AUTHENTICATING_USER: + AUTH_LDAP_BIND_DN = environ.get('AUTH_LDAP_BIND_DN', '') + AUTH_LDAP_BIND_PASSWORD = _read_secret('auth_ldap_bind_password', environ.get('AUTH_LDAP_BIND_PASSWORD', '')) + +# Set a string template that describes any user’s distinguished name based on the username. +AUTH_LDAP_USER_DN_TEMPLATE = environ.get('AUTH_LDAP_USER_DN_TEMPLATE', None) + +# Enable STARTTLS for ldap authentication. +AUTH_LDAP_START_TLS = environ.get('AUTH_LDAP_START_TLS', 'False').lower() == 'true' + +# Include this setting if you want to ignore certificate errors. This might be needed to accept a self-signed cert. +# Note that this is a NetBox-specific setting which sets: +# ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER) +LDAP_IGNORE_CERT_ERRORS = environ.get('LDAP_IGNORE_CERT_ERRORS', 'False').lower() == 'true' + +# Include this setting if you want to validate the LDAP server certificates against a CA certificate directory on your server +# Note that this is a NetBox-specific setting which sets: +# ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, LDAP_CA_CERT_DIR) +LDAP_CA_CERT_DIR = environ.get('LDAP_CA_CERT_DIR', None) + +# Include this setting if you want to validate the LDAP server certificates against your own CA. +# Note that this is a NetBox-specific setting which sets: +# ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, LDAP_CA_CERT_FILE) +LDAP_CA_CERT_FILE = environ.get('LDAP_CA_CERT_FILE', None) + +AUTH_LDAP_USER_SEARCH_BASEDN = environ.get('AUTH_LDAP_USER_SEARCH_BASEDN', '') +AUTH_LDAP_USER_SEARCH_ATTR = environ.get('AUTH_LDAP_USER_SEARCH_ATTR', 'sAMAccountName') +AUTH_LDAP_USER_SEARCH_FILTER: str = environ.get( + 'AUTH_LDAP_USER_SEARCH_FILTER', f'({AUTH_LDAP_USER_SEARCH_ATTR}=%(user)s)' +) + +AUTH_LDAP_USER_SEARCH = LDAPSearch( + AUTH_LDAP_USER_SEARCH_BASEDN, ldap.SCOPE_SUBTREE, AUTH_LDAP_USER_SEARCH_FILTER +) + +# This search ought to return all groups to which the user belongs. django_auth_ldap uses this to determine group +# heirarchy. + +AUTH_LDAP_GROUP_SEARCH_BASEDN = environ.get('AUTH_LDAP_GROUP_SEARCH_BASEDN', '') +AUTH_LDAP_GROUP_SEARCH_CLASS = environ.get('AUTH_LDAP_GROUP_SEARCH_CLASS', 'group') + +AUTH_LDAP_GROUP_SEARCH_FILTER: str = environ.get( + 'AUTH_LDAP_GROUP_SEARCH_FILTER', f'(objectclass={AUTH_LDAP_GROUP_SEARCH_CLASS})' +) +AUTH_LDAP_GROUP_SEARCH = LDAPSearch( + AUTH_LDAP_GROUP_SEARCH_BASEDN, ldap.SCOPE_SUBTREE, AUTH_LDAP_GROUP_SEARCH_FILTER +) +AUTH_LDAP_GROUP_TYPE = _import_group_type(environ.get('AUTH_LDAP_GROUP_TYPE', 'GroupOfNamesType')) + +# Define a group required to login. +AUTH_LDAP_REQUIRE_GROUP = environ.get('AUTH_LDAP_REQUIRE_GROUP_DN') + +# Define special user types using groups. Exercise great caution when assigning superuser status. +AUTH_LDAP_USER_FLAGS_BY_GROUP = {} + +if AUTH_LDAP_REQUIRE_GROUP is not None: + AUTH_LDAP_USER_FLAGS_BY_GROUP = { + "is_active": environ.get('AUTH_LDAP_REQUIRE_GROUP_DN', ''), + "is_staff": environ.get('AUTH_LDAP_IS_ADMIN_DN', ''), + "is_superuser": environ.get('AUTH_LDAP_IS_SUPERUSER_DN', '') + } + +# For more granular permissions, we can map LDAP groups to Django groups. +AUTH_LDAP_FIND_GROUP_PERMS = environ.get('AUTH_LDAP_FIND_GROUP_PERMS', 'True').lower() == 'true' +AUTH_LDAP_MIRROR_GROUPS = environ.get('AUTH_LDAP_MIRROR_GROUPS', '').lower() == 'true' + +# Cache groups for one hour to reduce LDAP traffic +AUTH_LDAP_CACHE_TIMEOUT = int(environ.get('AUTH_LDAP_CACHE_TIMEOUT', 3600)) + +# Populate the Django user from the LDAP directory. +AUTH_LDAP_USER_ATTR_MAP = { + "first_name": environ.get('AUTH_LDAP_ATTR_FIRSTNAME', 'givenName'), + "last_name": environ.get('AUTH_LDAP_ATTR_LASTNAME', 'sn'), + "email": environ.get('AUTH_LDAP_ATTR_MAIL', 'mail') +} diff --git a/docker/v4.2.3/netbox/configuration/logging.py b/docker/v4.2.3/netbox/configuration/logging.py new file mode 100644 index 0000000..f145c5c --- /dev/null +++ b/docker/v4.2.3/netbox/configuration/logging.py @@ -0,0 +1,72 @@ +from os import environ + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + }, + }, + 'loggers': { + '': { # root logger + 'handlers': ['console'], + 'level': 'DEBUG' if environ.get('DEBUG', 'false').lower() == 'true' else 'INFO', + }, + }, +} +# # Remove first comment(#) on each line to implement this working logging example. +# # Add LOGLEVEL environment variable to netbox if you use this example & want a different log level. +# from os import environ + +# # Set LOGLEVEL in netbox.env or docker-compose.overide.yml to override a logging level of INFO. +# LOGLEVEL = environ.get('LOGLEVEL', 'INFO') + +# LOGGING = { + +# 'version': 1, +# 'disable_existing_loggers': False, +# 'formatters': { +# 'verbose': { +# 'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}', +# 'style': '{', +# }, +# 'simple': { +# 'format': '{levelname} {message}', +# 'style': '{', +# }, +# }, +# 'filters': { +# 'require_debug_false': { +# '()': 'django.utils.log.RequireDebugFalse', +# }, +# }, +# 'handlers': { +# 'console': { +# 'level': LOGLEVEL, +# 'filters': ['require_debug_false'], +# 'class': 'logging.StreamHandler', +# 'formatter': 'simple' +# }, +# 'mail_admins': { +# 'level': 'ERROR', +# 'class': 'django.utils.log.AdminEmailHandler', +# 'filters': ['require_debug_false'] +# } +# }, +# 'loggers': { +# 'django': { +# 'handlers': ['console'], +# 'propagate': True, +# }, +# 'django.request': { +# 'handlers': ['mail_admins'], +# 'level': 'ERROR', +# 'propagate': False, +# }, +# 'django_auth_ldap': { +# 'handlers': ['console',], +# 'level': LOGLEVEL, +# } +# } +# } diff --git a/docker/v4.2.3/netbox/configuration/plugins.py b/docker/v4.2.3/netbox/configuration/plugins.py new file mode 100644 index 0000000..c6deec2 --- /dev/null +++ b/docker/v4.2.3/netbox/configuration/plugins.py @@ -0,0 +1,29 @@ +# Add your plugins and plugin settings here. +# Of course uncomment this file out. + +# To learn how to build images with your required plugins +# See https://github.com/netbox-community/netbox-docker/wiki/Using-Netbox-Plugins + +PLUGINS = [ + "netbox_diode_plugin", + "netbox_branching", +] + +# PLUGINS_CONFIG = { +# "netbox_diode_plugin": { +# # Auto-provision users for Diode plugin +# "auto_provision_users": True, +# +# # Diode gRPC target for communication with Diode server +# "diode_target_override": "grpc://localhost:8080/diode", +# +# # User allowed for Diode to NetBox communication +# "diode_to_netbox_username": "diode-to-netbox", +# +# # User allowed for NetBox to Diode communication +# "netbox_to_diode_username": "netbox-to-diode", +# +# # User allowed for data ingestion +# "diode_username": "diode-ingestion", +# }, +# } diff --git a/docker/v4.2.3/netbox/docker-entrypoint.sh b/docker/v4.2.3/netbox/docker-entrypoint.sh new file mode 100755 index 0000000..fb25e67 --- /dev/null +++ b/docker/v4.2.3/netbox/docker-entrypoint.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# Runs on every start of the NetBox Docker container + +# Stop when an error occures +set -e + +# Allows NetBox to be run as non-root users +umask 002 + +# Load correct Python3 env +# shellcheck disable=SC1091 +source /opt/netbox/venv/bin/activate + +# Try to connect to the DB +DB_WAIT_TIMEOUT=${DB_WAIT_TIMEOUT-3} +MAX_DB_WAIT_TIME=${MAX_DB_WAIT_TIME-30} +CUR_DB_WAIT_TIME=0 +while [ "${CUR_DB_WAIT_TIME}" -lt "${MAX_DB_WAIT_TIME}" ]; do + # Read and truncate connection error tracebacks to last line by default + exec {psfd}< <(./manage.py showmigrations 2>&1) + read -rd '' DB_ERR <&$psfd || : + exec {psfd}<&- + wait $! && break + if [ -n "$DB_WAIT_DEBUG" ]; then + echo "$DB_ERR" + else + readarray -tn 0 DB_ERR_LINES <<<"$DB_ERR" + echo "${DB_ERR_LINES[@]: -1}" + echo "[ Use DB_WAIT_DEBUG=1 in netbox.env to print full traceback for errors here ]" + fi + echo "⏳ Waiting on DB... (${CUR_DB_WAIT_TIME}s / ${MAX_DB_WAIT_TIME}s)" + sleep "${DB_WAIT_TIMEOUT}" + CUR_DB_WAIT_TIME=$((CUR_DB_WAIT_TIME + DB_WAIT_TIMEOUT)) +done +if [ "${CUR_DB_WAIT_TIME}" -ge "${MAX_DB_WAIT_TIME}" ]; then + echo "❌ Waited ${MAX_DB_WAIT_TIME}s or more for the DB to become ready." + exit 1 +fi +# Check if update is needed +if ! ./manage.py migrate --check >/dev/null 2>&1; then + echo "⚙️ Applying database migrations" + ./manage.py migrate --no-input + echo "⚙️ Running trace_paths" + ./manage.py trace_paths --no-input + echo "⚙️ Removing stale content types" + ./manage.py remove_stale_contenttypes --no-input + echo "⚙️ Removing expired user sessions" + ./manage.py clearsessions + echo "⚙️ Building search index (lazy)" + ./manage.py reindex --lazy +fi + +# Create Superuser if required +if [ "$SKIP_SUPERUSER" == "true" ]; then + echo "↩️ Skip creating the superuser" +else + if [ -z ${SUPERUSER_NAME+x} ]; then + SUPERUSER_NAME='admin' + fi + if [ -z ${SUPERUSER_EMAIL+x} ]; then + SUPERUSER_EMAIL='admin@example.com' + fi + if [ -f "/run/secrets/superuser_password" ]; then + SUPERUSER_PASSWORD="$( dict: """Convert the change set to a dictionary.""" - return { + d = { "id": self.id, "changes": [change.to_dict() for change in self.changes], "branch": self.branch, } + if self.warnings: + d["warnings"] = self.warnings + return d def validate(self) -> dict[str, list[str]]: """Validate basics of the change set data.""" @@ -244,7 +248,6 @@ class AutoSlug: field_name: str value: str - def error_from_validation_error(e, object_name): """Convert a from DRF ValidationError to a ChangeSetException.""" errors = {} @@ -277,7 +280,7 @@ def harmonize_formats(data): case datetime.date(): return data.strftime("%Y-%m-%d") case NumericRange(): - return (data.lower, data.upper-1) + return [data.lower, data.upper-1] case netaddr.IPNetwork() | EUI() | ZoneInfo(): return str(data) case _: diff --git a/netbox_diode_plugin/api/compat.py b/netbox_diode_plugin/api/compat.py index cf31f8c..836e999 100644 --- a/netbox_diode_plugin/api/compat.py +++ b/netbox_diode_plugin/api/compat.py @@ -84,3 +84,20 @@ def _migrate_contact_group(data: dict): if data.get("groups") is None: data["groups"] = [group] # else ignored. + +@diode_migration(min_version="4.2.0", max_version="4.2.99", object_type="ipam.service") +def _migrate_service_parent_object_down(data: dict): + """Transforms ipam.service parent_object to device and virtual_machine.""" + parent_object_vm = data.pop("parent_object_virtual_machine", None) + if parent_object_vm and data.get("virtual_machine") is None: + data["virtual_machine"] = parent_object_vm + parent_object_device = data.pop("parent_object_device", None) + if parent_object_device and data.get("device") is None: + data["device"] = parent_object_device + +@diode_migration(min_version="4.2.0", max_version="4.2.99", object_type="tenancy.contact") +def _migrate_contact_group_down(data: dict): + """Transforms tenancy.contact groups to group.""" + groups = data.pop("groups", None) + if groups and len(groups) == 1: + data["group"] = groups[0] diff --git a/netbox_diode_plugin/api/differ.py b/netbox_diode_plugin/api/differ.py index 40d7e93..37f7333 100644 --- a/netbox_diode_plugin/api/differ.py +++ b/netbox_diode_plugin/api/differ.py @@ -5,6 +5,7 @@ import copy import datetime import logging +from collections import defaultdict from django.contrib.contenttypes.models import ContentType from rest_framework import serializers @@ -27,9 +28,6 @@ logger = logging.getLogger(__name__) -SUPPORTED_MODELS = extract_supported_models() - - def prechange_data_from_instance(instance) -> dict: # noqa: C901 """Convert model instance data to a dictionary format for comparison.""" prechange_data = {} @@ -40,10 +38,11 @@ def prechange_data_from_instance(instance) -> dict: # noqa: C901 model_class = instance.__class__ object_type = f"{model_class._meta.app_label}.{model_class._meta.model_name}" - model = SUPPORTED_MODELS.get(object_type) + supported_models = extract_supported_models() + model = supported_models.get(object_type) if not model: raise serializers.ValidationError({ - NON_FIELD_ERRORS: [f"Model {model_class.__name__} is not supported"] + NON_FIELD_ERRORS: [f"{object_type} is not supported in this version."] }) fields = model.get("fields", {}) @@ -66,17 +65,11 @@ def prechange_data_from_instance(instance) -> dict: # noqa: C901 if hasattr(value, "all"): # Handle many-to-many and many-to-one relationships # For any relationship that has an 'all' method, get all related objects' primary keys prechange_data[field_name] = ( - sorted([item.pk for item in value.all()] if value is not None else []) + sorted([_pk_or_content_type_ref(item) for item in value.all()] if value is not None else []) ) - elif hasattr( - value, "pk" - ): # Handle regular related fields (ForeignKey, OneToOne) - # Handle ContentType fields - if isinstance(value, ContentType): - prechange_data[field_name] = f"{value.app_label}.{value.model}" - else: - # For regular related fields, get the primary key - prechange_data[field_name] = value.pk if value is not None else None + elif hasattr(value, "pk"): + # Handle regular related fields (ForeignKey, OneToOne) andContentType fields + prechange_data[field_name] = _pk_or_content_type_ref(value) else: prechange_data[field_name] = value @@ -93,6 +86,11 @@ def prechange_data_from_instance(instance) -> dict: # noqa: C901 return prechange_data +def _pk_or_content_type_ref(value): + if isinstance(value, ContentType): + return f"{value.app_label}.{value.model}" + # For regular related fields, get the primary key + return value.pk if value is not None else None def clean_diff_data(data: dict, exclude_empty_values: bool = True) -> dict: """Clean diff data by removing null values.""" @@ -176,7 +174,9 @@ def _generate_changeset(entity: dict, object_type: str) -> ChangeSetResult: """Generate a changeset for an entity.""" change_set = ChangeSet() - entities = transform_proto_json(entity, object_type, SUPPORTED_MODELS) + warnings = {} + supported_models = extract_supported_models() + entities = transform_proto_json(entity, object_type, supported_models) by_uuid = {x['_uuid']: x for x in entities} for entity in entities: prechange_data = {} @@ -185,7 +185,7 @@ def _generate_changeset(entity: dict, object_type: str) -> ChangeSetResult: object_type = entity.pop("_object_type") _ = entity.pop("_uuid") instance = entity.pop("_instance", None) - + _merge_warnings(warnings, object_type, entity.pop("_warnings", None)) if instance: # the prior state is another new object... if isinstance(instance, str): @@ -222,6 +222,8 @@ def _generate_changeset(entity: dict, object_type: str) -> ChangeSetResult: if errors := change_set.validate(): raise ChangeSetException("Invalid change set", errors) + if warnings: + change_set.warnings = warnings cs = ChangeSetResult( id=change_set.id, @@ -253,3 +255,13 @@ def _merge_reference_list(prechange_list: list, postchange_list: list) -> list: result = set(prechange_list) result.update(postchange_list) return sort_ints_first(result) + +def _merge_warnings(warnings: dict, object_type: str, entity_warnings: dict): + """Merge warnings for an object type.""" + if not entity_warnings: + return + + if object_type not in warnings: + warnings[object_type] = defaultdict(list) + for key, value in entity_warnings.items(): + warnings[object_type][key] += value diff --git a/netbox_diode_plugin/api/matcher.py b/netbox_diode_plugin/api/matcher.py index 4d2dca5..6e7c6b9 100644 --- a/netbox_diode_plugin/api/matcher.py +++ b/netbox_diode_plugin/api/matcher.py @@ -116,9 +116,15 @@ "ipam.vlan": lambda: [ ObjectMatchCriteria( fields=("vid",), - name="logical_vlan_vid_no_group_or_svlan", + name="logical_vlan_vid_no_group_or_svlan_or_site", model_class=get_object_type_model("ipam.vlan"), - condition=Q(group__isnull=True, qinq_svlan__isnull=True), + condition=Q(group__isnull=True, qinq_svlan__isnull=True, site__isnull=True), + ), + ObjectMatchCriteria( + fields=("vid", "site"), + name="logical_vlan_in_site", + model_class=get_object_type_model("ipam.vlan"), + condition=Q(group__isnull=True, qinq_svlan__isnull=True, site__isnull=False), ), ], "ipam.vlangroup": lambda: [ @@ -238,6 +244,13 @@ min_version="4.3.0", ) ], + "extras.journalentry": lambda: [ + ObjectMatchCriteria( + fields=("assigned_object_id", "assigned_object_type", "comments"), + name="logical_journal_entry_assigned_object_comments", + model_class=get_object_type_model("extras.journalentry"), + ) + ], } @dataclass @@ -799,19 +812,30 @@ def _fingerprint_all(data: dict, object_type: str|None = None) -> str: if data is None: return None - values = ["object_type", object_type] - for k, v in sorted(data.items()): - if k.startswith("_"): - continue - values.append(k) - if isinstance(v, list | tuple): - values.extend(sorted(v)) - elif isinstance(v, dict): - values.append(_fingerprint_all(v)) - else: - values.append(v) - - return hash(tuple(values)) + try: + values = ["object_type", object_type] + for k, v in sorted(data.items()): + if k.startswith("_"): + continue + values.append(k) + if isinstance(v, list | tuple): + values.extend(sorted(_as_tuples(v))) + elif isinstance(v, dict): + values.append(_fingerprint_all(v)) + else: + values.append(v) + + return hash(tuple(values)) + except Exception as e: + logger.error(f"Error fingerprinting data: {e}") + raise + +def _as_tuples(vs): + if isinstance(vs, list): + return tuple(_as_tuples(v) for v in vs) + if isinstance(vs, dict): + return tuple((k, _as_tuples(v)) for k, v in vs.items()) + return vs def fingerprints(data: dict, object_type: str) -> list[str]: """ diff --git a/netbox_diode_plugin/api/plugin_utils.py b/netbox_diode_plugin/api/plugin_utils.py index 5d9e31c..6164c39 100644 --- a/netbox_diode_plugin/api/plugin_utils.py +++ b/netbox_diode_plugin/api/plugin_utils.py @@ -1,19 +1,21 @@ """Diode plugin helpers.""" # Generated code. DO NOT EDIT. -# Timestamp: 2025-04-13 16:50:25Z +# Timestamp: 2025-07-23 01:46:44Z +from dataclasses import dataclass import datetime import decimal -import logging -from dataclasses import dataclass from functools import lru_cache +import json +import logging +import re from typing import Type -import netaddr from core.models import ObjectType as NetBoxType from django.contrib.contenttypes.models import ContentType from django.db import models +import netaddr from rest_framework.exceptions import ValidationError logger = logging.getLogger(__name__) @@ -137,6 +139,11 @@ class RefInfo: 'wireless_lan': RefInfo(object_type='wireless.wirelesslan', field_name='object', is_generic=True), 'wireless_lan_group': RefInfo(object_type='wireless.wirelesslangroup', field_name='object', is_generic=True), 'wireless_link': RefInfo(object_type='wireless.wirelesslink', field_name='object', is_generic=True), + 'custom_field': RefInfo(object_type='extras.customfield', field_name='object', is_generic=True), + 'custom_field_choice_set': RefInfo(object_type='extras.customfieldchoiceset', field_name='object', is_generic=True), + 'journal_entry': RefInfo(object_type='extras.journalentry', field_name='object', is_generic=True), + 'module_type_profile': RefInfo(object_type='dcim.moduletypeprofile', field_name='object', is_generic=True), + 'custom_link': RefInfo(object_type='extras.customlink', field_name='object', is_generic=True), }, 'circuits.circuit': { 'assignments': RefInfo(object_type='circuits.circuitgroupassignment', field_name='assignments', is_many=True), @@ -243,6 +250,7 @@ class RefInfo: 'tags': RefInfo(object_type='extras.tag', field_name='tags', is_many=True), }, 'dcim.devicerole': { + 'parent': RefInfo(object_type='dcim.devicerole', field_name='parent'), 'tags': RefInfo(object_type='extras.tag', field_name='tags', is_many=True), }, 'dcim.devicetype': { @@ -317,6 +325,10 @@ class RefInfo: }, 'dcim.moduletype': { 'manufacturer': RefInfo(object_type='dcim.manufacturer', field_name='manufacturer'), + 'profile': RefInfo(object_type='dcim.moduletypeprofile', field_name='profile'), + 'tags': RefInfo(object_type='extras.tag', field_name='tags', is_many=True), + }, + 'dcim.moduletypeprofile': { 'tags': RefInfo(object_type='extras.tag', field_name='tags', is_many=True), }, 'dcim.platform': { @@ -396,6 +408,105 @@ class RefInfo: 'tags': RefInfo(object_type='extras.tag', field_name='tags', is_many=True), 'tenant': RefInfo(object_type='tenancy.tenant', field_name='tenant'), }, + 'extras.customfield': { + 'choice_set': RefInfo(object_type='extras.customfieldchoiceset', field_name='choice_set'), + }, + 'extras.journalentry': { + 'assigned_object_asn': RefInfo(object_type='ipam.asn', field_name='assigned_object', is_generic=True), + 'assigned_object_asn_range': RefInfo(object_type='ipam.asnrange', field_name='assigned_object', is_generic=True), + 'assigned_object_aggregate': RefInfo(object_type='ipam.aggregate', field_name='assigned_object', is_generic=True), + 'assigned_object_cable': RefInfo(object_type='dcim.cable', field_name='assigned_object', is_generic=True), + 'assigned_object_cable_path': RefInfo(object_type='dcim.cablepath', field_name='assigned_object', is_generic=True), + 'assigned_object_cable_termination': RefInfo(object_type='dcim.cabletermination', field_name='assigned_object', is_generic=True), + 'assigned_object_circuit': RefInfo(object_type='circuits.circuit', field_name='assigned_object', is_generic=True), + 'assigned_object_circuit_group': RefInfo(object_type='circuits.circuitgroup', field_name='assigned_object', is_generic=True), + 'assigned_object_circuit_group_assignment': RefInfo(object_type='circuits.circuitgroupassignment', field_name='assigned_object', is_generic=True), + 'assigned_object_circuit_termination': RefInfo(object_type='circuits.circuittermination', field_name='assigned_object', is_generic=True), + 'assigned_object_circuit_type': RefInfo(object_type='circuits.circuittype', field_name='assigned_object', is_generic=True), + 'assigned_object_cluster': RefInfo(object_type='virtualization.cluster', field_name='assigned_object', is_generic=True), + 'assigned_object_cluster_group': RefInfo(object_type='virtualization.clustergroup', field_name='assigned_object', is_generic=True), + 'assigned_object_cluster_type': RefInfo(object_type='virtualization.clustertype', field_name='assigned_object', is_generic=True), + 'assigned_object_console_port': RefInfo(object_type='dcim.consoleport', field_name='assigned_object', is_generic=True), + 'assigned_object_console_server_port': RefInfo(object_type='dcim.consoleserverport', field_name='assigned_object', is_generic=True), + 'assigned_object_contact': RefInfo(object_type='tenancy.contact', field_name='assigned_object', is_generic=True), + 'assigned_object_contact_assignment': RefInfo(object_type='tenancy.contactassignment', field_name='assigned_object', is_generic=True), + 'assigned_object_contact_group': RefInfo(object_type='tenancy.contactgroup', field_name='assigned_object', is_generic=True), + 'assigned_object_contact_role': RefInfo(object_type='tenancy.contactrole', field_name='assigned_object', is_generic=True), + 'assigned_object_custom_field': RefInfo(object_type='extras.customfield', field_name='assigned_object', is_generic=True), + 'assigned_object_custom_field_choice_set': RefInfo(object_type='extras.customfieldchoiceset', field_name='assigned_object', is_generic=True), + 'assigned_object_device': RefInfo(object_type='dcim.device', field_name='assigned_object', is_generic=True), + 'assigned_object_device_bay': RefInfo(object_type='dcim.devicebay', field_name='assigned_object', is_generic=True), + 'assigned_object_device_role': RefInfo(object_type='dcim.devicerole', field_name='assigned_object', is_generic=True), + 'assigned_object_device_type': RefInfo(object_type='dcim.devicetype', field_name='assigned_object', is_generic=True), + 'assigned_object_fhrp_group': RefInfo(object_type='ipam.fhrpgroup', field_name='assigned_object', is_generic=True), + 'assigned_object_fhrp_group_assignment': RefInfo(object_type='ipam.fhrpgroupassignment', field_name='assigned_object', is_generic=True), + 'assigned_object_front_port': RefInfo(object_type='dcim.frontport', field_name='assigned_object', is_generic=True), + 'assigned_object_ike_policy': RefInfo(object_type='vpn.ikepolicy', field_name='assigned_object', is_generic=True), + 'assigned_object_ike_proposal': RefInfo(object_type='vpn.ikeproposal', field_name='assigned_object', is_generic=True), + 'assigned_object_ip_address': RefInfo(object_type='ipam.ipaddress', field_name='assigned_object', is_generic=True), + 'assigned_object_ip_range': RefInfo(object_type='ipam.iprange', field_name='assigned_object', is_generic=True), + 'assigned_object_ip_sec_policy': RefInfo(object_type='vpn.ipsecpolicy', field_name='assigned_object', is_generic=True), + 'assigned_object_ip_sec_profile': RefInfo(object_type='vpn.ipsecprofile', field_name='assigned_object', is_generic=True), + 'assigned_object_ip_sec_proposal': RefInfo(object_type='vpn.ipsecproposal', field_name='assigned_object', is_generic=True), + 'assigned_object_interface': RefInfo(object_type='dcim.interface', field_name='assigned_object', is_generic=True), + 'assigned_object_inventory_item': RefInfo(object_type='dcim.inventoryitem', field_name='assigned_object', is_generic=True), + 'assigned_object_inventory_item_role': RefInfo(object_type='dcim.inventoryitemrole', field_name='assigned_object', is_generic=True), + 'assigned_object_journal_entry': RefInfo(object_type='extras.journalentry', field_name='assigned_object', is_generic=True), + 'assigned_object_l2vpn': RefInfo(object_type='vpn.l2vpn', field_name='assigned_object', is_generic=True), + 'assigned_object_l2vpn_termination': RefInfo(object_type='vpn.l2vpntermination', field_name='assigned_object', is_generic=True), + 'assigned_object_location': RefInfo(object_type='dcim.location', field_name='assigned_object', is_generic=True), + 'assigned_object_mac_address': RefInfo(object_type='dcim.macaddress', field_name='assigned_object', is_generic=True), + 'assigned_object_manufacturer': RefInfo(object_type='dcim.manufacturer', field_name='assigned_object', is_generic=True), + 'assigned_object_module': RefInfo(object_type='dcim.module', field_name='assigned_object', is_generic=True), + 'assigned_object_module_bay': RefInfo(object_type='dcim.modulebay', field_name='assigned_object', is_generic=True), + 'assigned_object_module_type': RefInfo(object_type='dcim.moduletype', field_name='assigned_object', is_generic=True), + 'assigned_object_module_type_profile': RefInfo(object_type='dcim.moduletypeprofile', field_name='assigned_object', is_generic=True), + 'assigned_object_platform': RefInfo(object_type='dcim.platform', field_name='assigned_object', is_generic=True), + 'assigned_object_power_feed': RefInfo(object_type='dcim.powerfeed', field_name='assigned_object', is_generic=True), + 'assigned_object_power_outlet': RefInfo(object_type='dcim.poweroutlet', field_name='assigned_object', is_generic=True), + 'assigned_object_power_panel': RefInfo(object_type='dcim.powerpanel', field_name='assigned_object', is_generic=True), + 'assigned_object_power_port': RefInfo(object_type='dcim.powerport', field_name='assigned_object', is_generic=True), + 'assigned_object_prefix': RefInfo(object_type='ipam.prefix', field_name='assigned_object', is_generic=True), + 'assigned_object_provider': RefInfo(object_type='circuits.provider', field_name='assigned_object', is_generic=True), + 'assigned_object_provider_account': RefInfo(object_type='circuits.provideraccount', field_name='assigned_object', is_generic=True), + 'assigned_object_provider_network': RefInfo(object_type='circuits.providernetwork', field_name='assigned_object', is_generic=True), + 'assigned_object_rir': RefInfo(object_type='ipam.rir', field_name='assigned_object', is_generic=True), + 'assigned_object_rack': RefInfo(object_type='dcim.rack', field_name='assigned_object', is_generic=True), + 'assigned_object_rack_reservation': RefInfo(object_type='dcim.rackreservation', field_name='assigned_object', is_generic=True), + 'assigned_object_rack_role': RefInfo(object_type='dcim.rackrole', field_name='assigned_object', is_generic=True), + 'assigned_object_rack_type': RefInfo(object_type='dcim.racktype', field_name='assigned_object', is_generic=True), + 'assigned_object_rear_port': RefInfo(object_type='dcim.rearport', field_name='assigned_object', is_generic=True), + 'assigned_object_region': RefInfo(object_type='dcim.region', field_name='assigned_object', is_generic=True), + 'assigned_object_role': RefInfo(object_type='ipam.role', field_name='assigned_object', is_generic=True), + 'assigned_object_route_target': RefInfo(object_type='ipam.routetarget', field_name='assigned_object', is_generic=True), + 'assigned_object_service': RefInfo(object_type='ipam.service', field_name='assigned_object', is_generic=True), + 'assigned_object_site': RefInfo(object_type='dcim.site', field_name='assigned_object', is_generic=True), + 'assigned_object_site_group': RefInfo(object_type='dcim.sitegroup', field_name='assigned_object', is_generic=True), + 'assigned_object_tag': RefInfo(object_type='extras.tag', field_name='assigned_object', is_generic=True), + 'assigned_object_tenant': RefInfo(object_type='tenancy.tenant', field_name='assigned_object', is_generic=True), + 'assigned_object_tenant_group': RefInfo(object_type='tenancy.tenantgroup', field_name='assigned_object', is_generic=True), + 'assigned_object_tunnel': RefInfo(object_type='vpn.tunnel', field_name='assigned_object', is_generic=True), + 'assigned_object_tunnel_group': RefInfo(object_type='vpn.tunnelgroup', field_name='assigned_object', is_generic=True), + 'assigned_object_tunnel_termination': RefInfo(object_type='vpn.tunneltermination', field_name='assigned_object', is_generic=True), + 'assigned_object_vlan': RefInfo(object_type='ipam.vlan', field_name='assigned_object', is_generic=True), + 'assigned_object_vlan_group': RefInfo(object_type='ipam.vlangroup', field_name='assigned_object', is_generic=True), + 'assigned_object_vlan_translation_policy': RefInfo(object_type='ipam.vlantranslationpolicy', field_name='assigned_object', is_generic=True), + 'assigned_object_vlan_translation_rule': RefInfo(object_type='ipam.vlantranslationrule', field_name='assigned_object', is_generic=True), + 'assigned_object_vm_interface': RefInfo(object_type='virtualization.vminterface', field_name='assigned_object', is_generic=True), + 'assigned_object_vrf': RefInfo(object_type='ipam.vrf', field_name='assigned_object', is_generic=True), + 'assigned_object_virtual_chassis': RefInfo(object_type='dcim.virtualchassis', field_name='assigned_object', is_generic=True), + 'assigned_object_virtual_circuit': RefInfo(object_type='circuits.virtualcircuit', field_name='assigned_object', is_generic=True), + 'assigned_object_virtual_circuit_termination': RefInfo(object_type='circuits.virtualcircuittermination', field_name='assigned_object', is_generic=True), + 'assigned_object_virtual_circuit_type': RefInfo(object_type='circuits.virtualcircuittype', field_name='assigned_object', is_generic=True), + 'assigned_object_virtual_device_context': RefInfo(object_type='dcim.virtualdevicecontext', field_name='assigned_object', is_generic=True), + 'assigned_object_virtual_disk': RefInfo(object_type='virtualization.virtualdisk', field_name='assigned_object', is_generic=True), + 'assigned_object_virtual_machine': RefInfo(object_type='virtualization.virtualmachine', field_name='assigned_object', is_generic=True), + 'assigned_object_wireless_lan': RefInfo(object_type='wireless.wirelesslan', field_name='assigned_object', is_generic=True), + 'assigned_object_wireless_lan_group': RefInfo(object_type='wireless.wirelesslangroup', field_name='assigned_object', is_generic=True), + 'assigned_object_wireless_link': RefInfo(object_type='wireless.wirelesslink', field_name='assigned_object', is_generic=True), + 'assigned_object_custom_link': RefInfo(object_type='extras.customlink', field_name='assigned_object', is_generic=True), + 'tags': RefInfo(object_type='extras.tag', field_name='tags', is_many=True), + }, 'ipam.aggregate': { 'rir': RefInfo(object_type='ipam.rir', field_name='rir'), 'tags': RefInfo(object_type='extras.tag', field_name='tags', is_many=True), @@ -504,6 +615,11 @@ class RefInfo: 'interface_wireless_lan': RefInfo(object_type='wireless.wirelesslan', field_name='interface', is_generic=True), 'interface_wireless_lan_group': RefInfo(object_type='wireless.wirelesslangroup', field_name='interface', is_generic=True), 'interface_wireless_link': RefInfo(object_type='wireless.wirelesslink', field_name='interface', is_generic=True), + 'interface_custom_field': RefInfo(object_type='extras.customfield', field_name='interface', is_generic=True), + 'interface_custom_field_choice_set': RefInfo(object_type='extras.customfieldchoiceset', field_name='interface', is_generic=True), + 'interface_journal_entry': RefInfo(object_type='extras.journalentry', field_name='interface', is_generic=True), + 'interface_module_type_profile': RefInfo(object_type='dcim.moduletypeprofile', field_name='interface', is_generic=True), + 'interface_custom_link': RefInfo(object_type='extras.customlink', field_name='interface', is_generic=True), }, 'ipam.ipaddress': { 'assigned_object_fhrp_group': RefInfo(object_type='ipam.fhrpgroup', field_name='assigned_object', is_generic=True), @@ -545,6 +661,7 @@ class RefInfo: 'device': RefInfo(object_type='dcim.device', field_name='device'), 'ipaddresses': RefInfo(object_type='ipam.ipaddress', field_name='ipaddresses', is_many=True), 'parent_object_device': RefInfo(object_type='dcim.device', field_name='parent_object', is_generic=True), + 'parent_object_fhrp_group': RefInfo(object_type='ipam.fhrpgroup', field_name='parent_object', is_generic=True), 'parent_object_virtual_machine': RefInfo(object_type='virtualization.virtualmachine', field_name='parent_object', is_generic=True), 'tags': RefInfo(object_type='extras.tag', field_name='tags', is_many=True), 'virtual_machine': RefInfo(object_type='virtualization.virtualmachine', field_name='virtual_machine'), @@ -566,6 +683,7 @@ class RefInfo: 'scope_site': RefInfo(object_type='dcim.site', field_name='scope', is_generic=True), 'scope_site_group': RefInfo(object_type='dcim.sitegroup', field_name='scope', is_generic=True), 'tags': RefInfo(object_type='extras.tag', field_name='tags', is_many=True), + 'tenant': RefInfo(object_type='tenancy.tenant', field_name='tenant'), }, 'ipam.vlantranslationrule': { 'policy': RefInfo(object_type='ipam.vlantranslationpolicy', field_name='policy'), @@ -671,6 +789,11 @@ class RefInfo: 'object_wireless_lan': RefInfo(object_type='wireless.wirelesslan', field_name='object', is_generic=True), 'object_wireless_lan_group': RefInfo(object_type='wireless.wirelesslangroup', field_name='object', is_generic=True), 'object_wireless_link': RefInfo(object_type='wireless.wirelesslink', field_name='object', is_generic=True), + 'object_custom_field': RefInfo(object_type='extras.customfield', field_name='object', is_generic=True), + 'object_custom_field_choice_set': RefInfo(object_type='extras.customfieldchoiceset', field_name='object', is_generic=True), + 'object_journal_entry': RefInfo(object_type='extras.journalentry', field_name='object', is_generic=True), + 'object_module_type_profile': RefInfo(object_type='dcim.moduletypeprofile', field_name='object', is_generic=True), + 'object_custom_link': RefInfo(object_type='extras.customlink', field_name='object', is_generic=True), 'role': RefInfo(object_type='tenancy.contactrole', field_name='role'), 'tags': RefInfo(object_type='extras.tag', field_name='tags', is_many=True), }, @@ -761,6 +884,96 @@ class RefInfo: 'assigned_object_interface': RefInfo(object_type='dcim.interface', field_name='assigned_object', is_generic=True), 'assigned_object_vlan': RefInfo(object_type='ipam.vlan', field_name='assigned_object', is_generic=True), 'assigned_object_vm_interface': RefInfo(object_type='virtualization.vminterface', field_name='assigned_object', is_generic=True), + 'assigned_object_asn': RefInfo(object_type='ipam.asn', field_name='assigned_object', is_generic=True), + 'assigned_object_asn_range': RefInfo(object_type='ipam.asnrange', field_name='assigned_object', is_generic=True), + 'assigned_object_aggregate': RefInfo(object_type='ipam.aggregate', field_name='assigned_object', is_generic=True), + 'assigned_object_cable': RefInfo(object_type='dcim.cable', field_name='assigned_object', is_generic=True), + 'assigned_object_cable_path': RefInfo(object_type='dcim.cablepath', field_name='assigned_object', is_generic=True), + 'assigned_object_cable_termination': RefInfo(object_type='dcim.cabletermination', field_name='assigned_object', is_generic=True), + 'assigned_object_circuit': RefInfo(object_type='circuits.circuit', field_name='assigned_object', is_generic=True), + 'assigned_object_circuit_group': RefInfo(object_type='circuits.circuitgroup', field_name='assigned_object', is_generic=True), + 'assigned_object_circuit_group_assignment': RefInfo(object_type='circuits.circuitgroupassignment', field_name='assigned_object', is_generic=True), + 'assigned_object_circuit_termination': RefInfo(object_type='circuits.circuittermination', field_name='assigned_object', is_generic=True), + 'assigned_object_circuit_type': RefInfo(object_type='circuits.circuittype', field_name='assigned_object', is_generic=True), + 'assigned_object_cluster': RefInfo(object_type='virtualization.cluster', field_name='assigned_object', is_generic=True), + 'assigned_object_cluster_group': RefInfo(object_type='virtualization.clustergroup', field_name='assigned_object', is_generic=True), + 'assigned_object_cluster_type': RefInfo(object_type='virtualization.clustertype', field_name='assigned_object', is_generic=True), + 'assigned_object_console_port': RefInfo(object_type='dcim.consoleport', field_name='assigned_object', is_generic=True), + 'assigned_object_console_server_port': RefInfo(object_type='dcim.consoleserverport', field_name='assigned_object', is_generic=True), + 'assigned_object_contact': RefInfo(object_type='tenancy.contact', field_name='assigned_object', is_generic=True), + 'assigned_object_contact_assignment': RefInfo(object_type='tenancy.contactassignment', field_name='assigned_object', is_generic=True), + 'assigned_object_contact_group': RefInfo(object_type='tenancy.contactgroup', field_name='assigned_object', is_generic=True), + 'assigned_object_contact_role': RefInfo(object_type='tenancy.contactrole', field_name='assigned_object', is_generic=True), + 'assigned_object_custom_field': RefInfo(object_type='extras.customfield', field_name='assigned_object', is_generic=True), + 'assigned_object_custom_field_choice_set': RefInfo(object_type='extras.customfieldchoiceset', field_name='assigned_object', is_generic=True), + 'assigned_object_device': RefInfo(object_type='dcim.device', field_name='assigned_object', is_generic=True), + 'assigned_object_device_bay': RefInfo(object_type='dcim.devicebay', field_name='assigned_object', is_generic=True), + 'assigned_object_device_role': RefInfo(object_type='dcim.devicerole', field_name='assigned_object', is_generic=True), + 'assigned_object_device_type': RefInfo(object_type='dcim.devicetype', field_name='assigned_object', is_generic=True), + 'assigned_object_fhrp_group': RefInfo(object_type='ipam.fhrpgroup', field_name='assigned_object', is_generic=True), + 'assigned_object_fhrp_group_assignment': RefInfo(object_type='ipam.fhrpgroupassignment', field_name='assigned_object', is_generic=True), + 'assigned_object_front_port': RefInfo(object_type='dcim.frontport', field_name='assigned_object', is_generic=True), + 'assigned_object_ike_policy': RefInfo(object_type='vpn.ikepolicy', field_name='assigned_object', is_generic=True), + 'assigned_object_ike_proposal': RefInfo(object_type='vpn.ikeproposal', field_name='assigned_object', is_generic=True), + 'assigned_object_ip_address': RefInfo(object_type='ipam.ipaddress', field_name='assigned_object', is_generic=True), + 'assigned_object_ip_range': RefInfo(object_type='ipam.iprange', field_name='assigned_object', is_generic=True), + 'assigned_object_ip_sec_policy': RefInfo(object_type='vpn.ipsecpolicy', field_name='assigned_object', is_generic=True), + 'assigned_object_ip_sec_profile': RefInfo(object_type='vpn.ipsecprofile', field_name='assigned_object', is_generic=True), + 'assigned_object_ip_sec_proposal': RefInfo(object_type='vpn.ipsecproposal', field_name='assigned_object', is_generic=True), + 'assigned_object_inventory_item': RefInfo(object_type='dcim.inventoryitem', field_name='assigned_object', is_generic=True), + 'assigned_object_inventory_item_role': RefInfo(object_type='dcim.inventoryitemrole', field_name='assigned_object', is_generic=True), + 'assigned_object_journal_entry': RefInfo(object_type='extras.journalentry', field_name='assigned_object', is_generic=True), + 'assigned_object_l2vpn': RefInfo(object_type='vpn.l2vpn', field_name='assigned_object', is_generic=True), + 'assigned_object_l2vpn_termination': RefInfo(object_type='vpn.l2vpntermination', field_name='assigned_object', is_generic=True), + 'assigned_object_location': RefInfo(object_type='dcim.location', field_name='assigned_object', is_generic=True), + 'assigned_object_mac_address': RefInfo(object_type='dcim.macaddress', field_name='assigned_object', is_generic=True), + 'assigned_object_manufacturer': RefInfo(object_type='dcim.manufacturer', field_name='assigned_object', is_generic=True), + 'assigned_object_module': RefInfo(object_type='dcim.module', field_name='assigned_object', is_generic=True), + 'assigned_object_module_bay': RefInfo(object_type='dcim.modulebay', field_name='assigned_object', is_generic=True), + 'assigned_object_module_type': RefInfo(object_type='dcim.moduletype', field_name='assigned_object', is_generic=True), + 'assigned_object_module_type_profile': RefInfo(object_type='dcim.moduletypeprofile', field_name='assigned_object', is_generic=True), + 'assigned_object_platform': RefInfo(object_type='dcim.platform', field_name='assigned_object', is_generic=True), + 'assigned_object_power_feed': RefInfo(object_type='dcim.powerfeed', field_name='assigned_object', is_generic=True), + 'assigned_object_power_outlet': RefInfo(object_type='dcim.poweroutlet', field_name='assigned_object', is_generic=True), + 'assigned_object_power_panel': RefInfo(object_type='dcim.powerpanel', field_name='assigned_object', is_generic=True), + 'assigned_object_power_port': RefInfo(object_type='dcim.powerport', field_name='assigned_object', is_generic=True), + 'assigned_object_prefix': RefInfo(object_type='ipam.prefix', field_name='assigned_object', is_generic=True), + 'assigned_object_provider': RefInfo(object_type='circuits.provider', field_name='assigned_object', is_generic=True), + 'assigned_object_provider_account': RefInfo(object_type='circuits.provideraccount', field_name='assigned_object', is_generic=True), + 'assigned_object_provider_network': RefInfo(object_type='circuits.providernetwork', field_name='assigned_object', is_generic=True), + 'assigned_object_rir': RefInfo(object_type='ipam.rir', field_name='assigned_object', is_generic=True), + 'assigned_object_rack': RefInfo(object_type='dcim.rack', field_name='assigned_object', is_generic=True), + 'assigned_object_rack_reservation': RefInfo(object_type='dcim.rackreservation', field_name='assigned_object', is_generic=True), + 'assigned_object_rack_role': RefInfo(object_type='dcim.rackrole', field_name='assigned_object', is_generic=True), + 'assigned_object_rack_type': RefInfo(object_type='dcim.racktype', field_name='assigned_object', is_generic=True), + 'assigned_object_rear_port': RefInfo(object_type='dcim.rearport', field_name='assigned_object', is_generic=True), + 'assigned_object_region': RefInfo(object_type='dcim.region', field_name='assigned_object', is_generic=True), + 'assigned_object_role': RefInfo(object_type='ipam.role', field_name='assigned_object', is_generic=True), + 'assigned_object_route_target': RefInfo(object_type='ipam.routetarget', field_name='assigned_object', is_generic=True), + 'assigned_object_service': RefInfo(object_type='ipam.service', field_name='assigned_object', is_generic=True), + 'assigned_object_site': RefInfo(object_type='dcim.site', field_name='assigned_object', is_generic=True), + 'assigned_object_site_group': RefInfo(object_type='dcim.sitegroup', field_name='assigned_object', is_generic=True), + 'assigned_object_tag': RefInfo(object_type='extras.tag', field_name='assigned_object', is_generic=True), + 'assigned_object_tenant': RefInfo(object_type='tenancy.tenant', field_name='assigned_object', is_generic=True), + 'assigned_object_tenant_group': RefInfo(object_type='tenancy.tenantgroup', field_name='assigned_object', is_generic=True), + 'assigned_object_tunnel': RefInfo(object_type='vpn.tunnel', field_name='assigned_object', is_generic=True), + 'assigned_object_tunnel_group': RefInfo(object_type='vpn.tunnelgroup', field_name='assigned_object', is_generic=True), + 'assigned_object_tunnel_termination': RefInfo(object_type='vpn.tunneltermination', field_name='assigned_object', is_generic=True), + 'assigned_object_vlan_group': RefInfo(object_type='ipam.vlangroup', field_name='assigned_object', is_generic=True), + 'assigned_object_vlan_translation_policy': RefInfo(object_type='ipam.vlantranslationpolicy', field_name='assigned_object', is_generic=True), + 'assigned_object_vlan_translation_rule': RefInfo(object_type='ipam.vlantranslationrule', field_name='assigned_object', is_generic=True), + 'assigned_object_vrf': RefInfo(object_type='ipam.vrf', field_name='assigned_object', is_generic=True), + 'assigned_object_virtual_chassis': RefInfo(object_type='dcim.virtualchassis', field_name='assigned_object', is_generic=True), + 'assigned_object_virtual_circuit': RefInfo(object_type='circuits.virtualcircuit', field_name='assigned_object', is_generic=True), + 'assigned_object_virtual_circuit_termination': RefInfo(object_type='circuits.virtualcircuittermination', field_name='assigned_object', is_generic=True), + 'assigned_object_virtual_circuit_type': RefInfo(object_type='circuits.virtualcircuittype', field_name='assigned_object', is_generic=True), + 'assigned_object_virtual_device_context': RefInfo(object_type='dcim.virtualdevicecontext', field_name='assigned_object', is_generic=True), + 'assigned_object_virtual_disk': RefInfo(object_type='virtualization.virtualdisk', field_name='assigned_object', is_generic=True), + 'assigned_object_virtual_machine': RefInfo(object_type='virtualization.virtualmachine', field_name='assigned_object', is_generic=True), + 'assigned_object_wireless_lan': RefInfo(object_type='wireless.wirelesslan', field_name='assigned_object', is_generic=True), + 'assigned_object_wireless_lan_group': RefInfo(object_type='wireless.wirelesslangroup', field_name='assigned_object', is_generic=True), + 'assigned_object_wireless_link': RefInfo(object_type='wireless.wirelesslink', field_name='assigned_object', is_generic=True), + 'assigned_object_custom_link': RefInfo(object_type='extras.customlink', field_name='assigned_object', is_generic=True), 'l2vpn': RefInfo(object_type='vpn.l2vpn', field_name='l2vpn'), 'tags': RefInfo(object_type='extras.tag', field_name='tags', is_many=True), }, @@ -864,6 +1077,11 @@ class RefInfo: 'termination_wireless_lan': RefInfo(object_type='wireless.wirelesslan', field_name='termination', is_generic=True), 'termination_wireless_lan_group': RefInfo(object_type='wireless.wirelesslangroup', field_name='termination', is_generic=True), 'termination_wireless_link': RefInfo(object_type='wireless.wirelesslink', field_name='termination', is_generic=True), + 'termination_custom_field': RefInfo(object_type='extras.customfield', field_name='termination', is_generic=True), + 'termination_custom_field_choice_set': RefInfo(object_type='extras.customfieldchoiceset', field_name='termination', is_generic=True), + 'termination_journal_entry': RefInfo(object_type='extras.journalentry', field_name='termination', is_generic=True), + 'termination_module_type_profile': RefInfo(object_type='dcim.moduletypeprofile', field_name='termination', is_generic=True), + 'termination_custom_link': RefInfo(object_type='extras.customlink', field_name='termination', is_generic=True), 'tunnel': RefInfo(object_type='vpn.tunnel', field_name='tunnel'), }, 'wireless.wirelesslan': { @@ -912,57 +1130,62 @@ def get_json_ref_info(object_type: str|Type[models.Model], json_field_name: str) 'dcim.consoleserverport': frozenset(['custom_fields', 'description', 'device', 'label', 'mark_connected', 'module', 'name', 'speed', 'tags', 'type']), 'dcim.device': frozenset(['airflow', 'asset_tag', 'cluster', 'comments', 'custom_fields', 'description', 'device_type', 'face', 'latitude', 'location', 'longitude', 'name', 'oob_ip', 'platform', 'position', 'primary_ip4', 'primary_ip6', 'rack', 'role', 'serial', 'site', 'status', 'tags', 'tenant', 'vc_position', 'vc_priority', 'virtual_chassis']), 'dcim.devicebay': frozenset(['custom_fields', 'description', 'device', 'installed_device', 'label', 'name', 'tags']), - 'dcim.devicerole': frozenset(['color', 'custom_fields', 'description', 'name', 'slug', 'tags', 'vm_role']), + 'dcim.devicerole': frozenset(['color', 'comments', 'custom_fields', 'description', 'name', 'parent', 'slug', 'tags', 'vm_role']), 'dcim.devicetype': frozenset(['airflow', 'comments', 'custom_fields', 'default_platform', 'description', 'exclude_from_utilization', 'is_full_depth', 'manufacturer', 'model', 'part_number', 'slug', 'subdevice_role', 'tags', 'u_height', 'weight', 'weight_unit']), 'dcim.frontport': frozenset(['color', 'custom_fields', 'description', 'device', 'label', 'mark_connected', 'module', 'name', 'rear_port', 'rear_port_position', 'tags', 'type']), 'dcim.interface': frozenset(['bridge', 'custom_fields', 'description', 'device', 'duplex', 'enabled', 'label', 'lag', 'mark_connected', 'mgmt_only', 'mode', 'module', 'mtu', 'name', 'parent', 'poe_mode', 'poe_type', 'primary_mac_address', 'qinq_svlan', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'rf_role', 'speed', 'tagged_vlans', 'tags', 'tx_power', 'type', 'untagged_vlan', 'vdcs', 'vlan_translation_policy', 'vrf', 'wireless_lans', 'wwn']), 'dcim.inventoryitem': frozenset(['asset_tag', 'component_id', 'component_type', 'custom_fields', 'description', 'device', 'discovered', 'label', 'manufacturer', 'name', 'parent', 'part_id', 'role', 'serial', 'status', 'tags']), 'dcim.inventoryitemrole': frozenset(['color', 'custom_fields', 'description', 'name', 'slug', 'tags']), - 'dcim.location': frozenset(['custom_fields', 'description', 'facility', 'name', 'parent', 'site', 'slug', 'status', 'tags', 'tenant']), + 'dcim.location': frozenset(['comments', 'custom_fields', 'description', 'facility', 'name', 'parent', 'site', 'slug', 'status', 'tags', 'tenant']), 'dcim.macaddress': frozenset(['assigned_object_id', 'assigned_object_type', 'comments', 'custom_fields', 'description', 'mac_address', 'tags']), 'dcim.manufacturer': frozenset(['custom_fields', 'description', 'name', 'slug', 'tags']), 'dcim.module': frozenset(['asset_tag', 'comments', 'custom_fields', 'description', 'device', 'module_bay', 'module_type', 'serial', 'status', 'tags']), 'dcim.modulebay': frozenset(['custom_fields', 'description', 'device', 'installed_module', 'label', 'module', 'name', 'position', 'tags']), - 'dcim.moduletype': frozenset(['airflow', 'comments', 'custom_fields', 'description', 'manufacturer', 'model', 'part_number', 'tags', 'weight', 'weight_unit']), + 'dcim.moduletype': frozenset(['airflow', 'attributes', 'comments', 'custom_fields', 'description', 'manufacturer', 'model', 'part_number', 'profile', 'tags', 'weight', 'weight_unit']), + 'dcim.moduletypeprofile': frozenset(['comments', 'custom_fields', 'description', 'name', 'schema', 'tags']), 'dcim.platform': frozenset(['custom_fields', 'description', 'manufacturer', 'name', 'slug', 'tags']), 'dcim.powerfeed': frozenset(['amperage', 'comments', 'custom_fields', 'description', 'mark_connected', 'max_utilization', 'name', 'phase', 'power_panel', 'rack', 'status', 'supply', 'tags', 'tenant', 'type', 'voltage']), - 'dcim.poweroutlet': frozenset(['color', 'custom_fields', 'description', 'device', 'feed_leg', 'label', 'mark_connected', 'module', 'name', 'power_port', 'tags', 'type']), + 'dcim.poweroutlet': frozenset(['color', 'custom_fields', 'description', 'device', 'feed_leg', 'label', 'mark_connected', 'module', 'name', 'power_port', 'status', 'tags', 'type']), 'dcim.powerpanel': frozenset(['comments', 'custom_fields', 'description', 'location', 'name', 'site', 'tags']), 'dcim.powerport': frozenset(['allocated_draw', 'custom_fields', 'description', 'device', 'label', 'mark_connected', 'maximum_draw', 'module', 'name', 'tags', 'type']), - 'dcim.rack': frozenset(['airflow', 'asset_tag', 'comments', 'custom_fields', 'desc_units', 'description', 'facility_id', 'form_factor', 'location', 'max_weight', 'mounting_depth', 'name', 'outer_depth', 'outer_unit', 'outer_width', 'rack_type', 'role', 'serial', 'site', 'starting_unit', 'status', 'tags', 'tenant', 'u_height', 'weight', 'weight_unit', 'width']), + 'dcim.rack': frozenset(['airflow', 'asset_tag', 'comments', 'custom_fields', 'desc_units', 'description', 'facility_id', 'form_factor', 'location', 'max_weight', 'mounting_depth', 'name', 'outer_depth', 'outer_height', 'outer_unit', 'outer_width', 'rack_type', 'role', 'serial', 'site', 'starting_unit', 'status', 'tags', 'tenant', 'u_height', 'weight', 'weight_unit', 'width']), 'dcim.rackreservation': frozenset(['comments', 'custom_fields', 'description', 'rack', 'tags', 'tenant', 'units']), 'dcim.rackrole': frozenset(['color', 'custom_fields', 'description', 'name', 'slug', 'tags']), - 'dcim.racktype': frozenset(['comments', 'custom_fields', 'desc_units', 'description', 'form_factor', 'manufacturer', 'max_weight', 'model', 'mounting_depth', 'outer_depth', 'outer_unit', 'outer_width', 'slug', 'starting_unit', 'tags', 'u_height', 'weight', 'weight_unit', 'width']), + 'dcim.racktype': frozenset(['comments', 'custom_fields', 'desc_units', 'description', 'form_factor', 'manufacturer', 'max_weight', 'model', 'mounting_depth', 'outer_depth', 'outer_height', 'outer_unit', 'outer_width', 'slug', 'starting_unit', 'tags', 'u_height', 'weight', 'weight_unit', 'width']), 'dcim.rearport': frozenset(['color', 'custom_fields', 'description', 'device', 'label', 'mark_connected', 'module', 'name', 'positions', 'tags', 'type']), - 'dcim.region': frozenset(['custom_fields', 'description', 'name', 'parent', 'slug', 'tags']), + 'dcim.region': frozenset(['comments', 'custom_fields', 'description', 'name', 'parent', 'slug', 'tags']), 'dcim.site': frozenset(['asns', 'comments', 'custom_fields', 'description', 'facility', 'group', 'latitude', 'longitude', 'name', 'physical_address', 'region', 'shipping_address', 'slug', 'status', 'tags', 'tenant', 'time_zone']), - 'dcim.sitegroup': frozenset(['custom_fields', 'description', 'name', 'parent', 'slug', 'tags']), + 'dcim.sitegroup': frozenset(['comments', 'custom_fields', 'description', 'name', 'parent', 'slug', 'tags']), 'dcim.virtualchassis': frozenset(['comments', 'custom_fields', 'description', 'domain', 'master', 'name', 'tags']), 'dcim.virtualdevicecontext': frozenset(['comments', 'custom_fields', 'description', 'device', 'identifier', 'name', 'primary_ip4', 'primary_ip6', 'status', 'tags', 'tenant']), - 'extras.tag': frozenset(['color', 'name', 'slug']), + 'extras.customfield': frozenset(['choice_set', 'comments', 'default', 'description', 'filter_logic', 'group_name', 'is_cloneable', 'label', 'name', 'object_types', 'related_object_filter', 'related_object_type', 'required', 'search_weight', 'type', 'ui_editable', 'ui_visible', 'unique', 'validation_maximum', 'validation_minimum', 'validation_regex', 'weight']), + 'extras.customfieldchoiceset': frozenset(['base_choices', 'description', 'extra_choices', 'name', 'order_alphabetically']), + 'extras.customlink': frozenset(['button_class', 'enabled', 'group_name', 'link_text', 'link_url', 'name', 'new_window', 'object_types', 'weight']), + 'extras.journalentry': frozenset(['assigned_object_id', 'assigned_object_type', 'comments', 'custom_fields', 'kind', 'tags']), + 'extras.tag': frozenset(['color', 'description', 'name', 'object_types', 'slug', 'weight']), 'ipam.aggregate': frozenset(['comments', 'custom_fields', 'date_added', 'description', 'prefix', 'rir', 'tags', 'tenant']), 'ipam.asn': frozenset(['asn', 'comments', 'custom_fields', 'description', 'rir', 'tags', 'tenant']), 'ipam.asnrange': frozenset(['custom_fields', 'description', 'end', 'name', 'rir', 'slug', 'start', 'tags', 'tenant']), 'ipam.fhrpgroup': frozenset(['auth_key', 'auth_type', 'comments', 'custom_fields', 'description', 'group_id', 'name', 'protocol', 'tags']), 'ipam.fhrpgroupassignment': frozenset(['group', 'interface_id', 'interface_type', 'priority']), 'ipam.ipaddress': frozenset(['address', 'assigned_object_id', 'assigned_object_type', 'comments', 'custom_fields', 'description', 'dns_name', 'nat_inside', 'role', 'status', 'tags', 'tenant', 'vrf']), - 'ipam.iprange': frozenset(['comments', 'custom_fields', 'description', 'end_address', 'mark_utilized', 'role', 'start_address', 'status', 'tags', 'tenant', 'vrf']), + 'ipam.iprange': frozenset(['comments', 'custom_fields', 'description', 'end_address', 'mark_populated', 'mark_utilized', 'role', 'start_address', 'status', 'tags', 'tenant', 'vrf']), 'ipam.prefix': frozenset(['comments', 'custom_fields', 'description', 'is_pool', 'mark_utilized', 'prefix', 'role', 'scope_id', 'scope_type', 'status', 'tags', 'tenant', 'vlan', 'vrf']), 'ipam.rir': frozenset(['custom_fields', 'description', 'is_private', 'name', 'slug', 'tags']), 'ipam.role': frozenset(['custom_fields', 'description', 'name', 'slug', 'tags', 'weight']), 'ipam.routetarget': frozenset(['comments', 'custom_fields', 'description', 'name', 'tags', 'tenant']), 'ipam.service': frozenset(['comments', 'custom_fields', 'description', 'device', 'ipaddresses', 'name', 'parent_object_id', 'parent_object_type', 'ports', 'protocol', 'tags', 'virtual_machine']), 'ipam.vlan': frozenset(['comments', 'custom_fields', 'description', 'group', 'name', 'qinq_role', 'qinq_svlan', 'role', 'site', 'status', 'tags', 'tenant', 'vid']), - 'ipam.vlangroup': frozenset(['custom_fields', 'description', 'name', 'scope_id', 'scope_type', 'slug', 'tags', 'vid_ranges']), + 'ipam.vlangroup': frozenset(['custom_fields', 'description', 'name', 'scope_id', 'scope_type', 'slug', 'tags', 'tenant', 'vid_ranges']), 'ipam.vlantranslationpolicy': frozenset(['description', 'name']), 'ipam.vlantranslationrule': frozenset(['description', 'local_vid', 'policy', 'remote_vid']), 'ipam.vrf': frozenset(['comments', 'custom_fields', 'description', 'enforce_unique', 'export_targets', 'import_targets', 'name', 'rd', 'tags', 'tenant']), 'tenancy.contact': frozenset(['address', 'comments', 'custom_fields', 'description', 'email', 'group', 'groups', 'link', 'name', 'phone', 'tags', 'title']), 'tenancy.contactassignment': frozenset(['contact', 'custom_fields', 'object_id', 'object_type', 'priority', 'role', 'tags']), - 'tenancy.contactgroup': frozenset(['custom_fields', 'description', 'name', 'parent', 'slug', 'tags']), + 'tenancy.contactgroup': frozenset(['comments', 'custom_fields', 'description', 'name', 'parent', 'slug', 'tags']), 'tenancy.contactrole': frozenset(['custom_fields', 'description', 'name', 'slug', 'tags']), 'tenancy.tenant': frozenset(['comments', 'custom_fields', 'description', 'group', 'name', 'slug', 'tags']), - 'tenancy.tenantgroup': frozenset(['custom_fields', 'description', 'name', 'parent', 'slug', 'tags']), + 'tenancy.tenantgroup': frozenset(['comments', 'custom_fields', 'description', 'name', 'parent', 'slug', 'tags']), 'virtualization.cluster': frozenset(['comments', 'custom_fields', 'description', 'group', 'name', 'scope_id', 'scope_type', 'status', 'tags', 'tenant', 'type']), 'virtualization.clustergroup': frozenset(['custom_fields', 'description', 'name', 'slug', 'tags']), 'virtualization.clustertype': frozenset(['custom_fields', 'description', 'name', 'slug', 'tags']), @@ -974,13 +1197,13 @@ def get_json_ref_info(object_type: str|Type[models.Model], json_field_name: str) 'vpn.ipsecpolicy': frozenset(['comments', 'custom_fields', 'description', 'name', 'pfs_group', 'proposals', 'tags']), 'vpn.ipsecprofile': frozenset(['comments', 'custom_fields', 'description', 'ike_policy', 'ipsec_policy', 'mode', 'name', 'tags']), 'vpn.ipsecproposal': frozenset(['authentication_algorithm', 'comments', 'custom_fields', 'description', 'encryption_algorithm', 'name', 'sa_lifetime_data', 'sa_lifetime_seconds', 'tags']), - 'vpn.l2vpn': frozenset(['comments', 'custom_fields', 'description', 'export_targets', 'identifier', 'import_targets', 'name', 'slug', 'tags', 'tenant', 'type']), + 'vpn.l2vpn': frozenset(['comments', 'custom_fields', 'description', 'export_targets', 'identifier', 'import_targets', 'name', 'slug', 'status', 'tags', 'tenant', 'type']), 'vpn.l2vpntermination': frozenset(['assigned_object_id', 'assigned_object_type', 'custom_fields', 'l2vpn', 'tags']), 'vpn.tunnel': frozenset(['comments', 'custom_fields', 'description', 'encapsulation', 'group', 'ipsec_profile', 'name', 'status', 'tags', 'tenant', 'tunnel_id']), 'vpn.tunnelgroup': frozenset(['custom_fields', 'description', 'name', 'slug', 'tags']), 'vpn.tunneltermination': frozenset(['custom_fields', 'outside_ip', 'role', 'tags', 'termination_id', 'termination_type', 'tunnel']), 'wireless.wirelesslan': frozenset(['auth_cipher', 'auth_psk', 'auth_type', 'comments', 'custom_fields', 'description', 'group', 'scope_id', 'scope_type', 'ssid', 'status', 'tags', 'tenant', 'vlan']), - 'wireless.wirelesslangroup': frozenset(['custom_fields', 'description', 'name', 'parent', 'slug', 'tags']), + 'wireless.wirelesslangroup': frozenset(['comments', 'custom_fields', 'description', 'name', 'parent', 'slug', 'tags']), 'wireless.wirelesslink': frozenset(['auth_cipher', 'auth_psk', 'auth_type', 'comments', 'custom_fields', 'description', 'distance', 'distance_unit', 'interface_a', 'interface_b', 'ssid', 'status', 'tags', 'tenant']), } @@ -989,6 +1212,10 @@ def legal_fields(object_type: str|Type[models.Model]) -> frozenset[str]: object_type = get_object_type(object_type) return _LEGAL_FIELDS.get(object_type, frozenset()) +def legal_object_types() -> frozenset[str]: + return frozenset(_LEGAL_FIELDS.keys()) + + _OBJECT_TYPE_PRIMARY_VALUE_FIELD_MAP = { 'ipam.asn': 'asn', 'dcim.devicetype': 'model', @@ -1025,16 +1252,42 @@ def ip_network_defaulting(value: str) -> str: except netaddr.AddrFormatError: raise ValueError(f'Invalid IP network value: {value}') -def collect_integer_pairs(value: list[int]) -> list[tuple[int, int]]: - if len(value) % 2 != 0: - raise ValueError('Array must have an even number of elements') - return sorted([(value[i], value[i+1]) for i in range(0, len(value), 2)]) +def parse_json(value: str) -> dict: + try: + return json.loads(value) + except json.JSONDecodeError: + return value + +def collect_tuples(value, tuple_length, sort=False, reverse=False, base_transform=None): + if len(value) % tuple_length != 0: + raise ValueError(f'Array length is not a multiple of {tuple_length}') + if base_transform is None: + base_transform = lambda v: v + vs = [[base_transform(v) for v in value[i:i+tuple_length]] for i in range(0, len(value), tuple_length)] + return sorted(vs, reverse=reverse) if sort else vs + -def for_all(transform): +def delimited_tuples(value, tuple_length, delimiter, sort=False, reverse=False, base_transform=None): + if base_transform is None: + base_transform = lambda v: v + vs = [] + for v in value: + vt = re.split(r'(? dict[str, dict]: - """Extract supported models from NetBox.""" - supported_models = discover_models(SUPPORTED_APPS) - - logger.debug(f"Supported models: {supported_models}") + """Extract supported models from installed NetBox apps / version.""" + start_ts = time.time() - models_to_process = supported_models extracted_models: dict[str, dict] = {} + possible_object_types = legal_object_types() - start_ts = time.time() - while models_to_process: - model = models_to_process.pop() + for object_type in possible_object_types: try: - fields, related_models = get_model_fields(model) + app_label, model_name = object_type.split(".") + model = apps.get_model(app_label, model_name) + except LookupError: + continue + + try: + fields = _get_model_fields(model) if not fields: continue - prerequisites = get_prerequisites(model, fields) - object_type = f"{model._meta.app_label}.{model._meta.model_name}" extracted_models[object_type] = { "fields": fields, - "prerequisites": prerequisites, "model": model, } - for related_model in related_models: - related_object_type = f"{related_model._meta.app_label}.{related_model._meta.model_name}" - if ( - related_object_type not in extracted_models - and related_object_type not in models_to_process - ): - models_to_process.append(related_model) except Exception as e: logger.error(f"extract_supported_models: {model.__name__} error: {e}") finish_ts = time.time() - lapsed_millis = (finish_ts - start_ts) * 1000 + elapsed_millis = (finish_ts - start_ts) * 1000 logger.info( - f"done extracting supported models in {lapsed_millis:.2f} milliseconds - extracted_models: {len(extracted_models)}" + f"done extracting supported diode models in {elapsed_millis:.2f} milliseconds - extracted_models: {len(extracted_models)}" ) return extracted_models +def _get_model_fields(model_class) -> dict: + """Get the fields for the model.""" + legal = legal_fields(model_class) + fields_info: dict[str, dict] = {} + for field in model_class._meta.get_fields(): + field_name = field.name + if field_name not in legal and field_name != 'id': + continue -def get_prerequisites(model_class, fields) -> list[dict[str, str]]: - """Get the prerequisite models for the model.""" - prerequisites: list[dict[str, str]] = [] - prerequisite_models = getattr(model_class, "prerequisite_models", []) - - for prereq in prerequisite_models: - prereq_model = apps.get_model(prereq) - - for field_name, field_info in fields.items(): - related_model = field_info.get("related_model") - prerequisite_info = { - "field_name": field_name, - "prerequisite_model": prereq_model, - } - if ( - prerequisite_info not in prerequisites - and related_model - and related_model.get("model_class_name") == prereq_model.__name__ - ): - prerequisites.append(prerequisite_info) - break - - return prerequisites - - -@lru_cache(maxsize=128) -def get_model_fields(model_class) -> tuple[dict, list]: - """Get the fields for the model ordered as they are in the serializer.""" - related_models_to_process = [] - - # Skip unsupported apps and excluded models - if ( - model_class._meta.app_label not in SUPPORTED_APPS - or model_class.__name__ in EXCLUDED_MODELS - ): - return {}, [] - - try: - # Get serializer fields to maintain order - serializer_class = get_serializer_for_model(model_class) - serializer_fields = serializer_class().get_fields() - serializer_fields_names = list(serializer_fields.keys()) - except Exception as e: - logger.error(f"Error getting serializer fields for model {model_class}: {e}") - return {}, [] - - # Get all model fields - model_fields = { - field.name: field - for field in model_class._meta.get_fields() - if field.__class__.__name__ not in ["CounterCacheField", "GenericRelation"] - } - - # Reorder fields to match serializer order - ordered_fields = { - field_name: model_fields[field_name] - for field_name in serializer_fields_names - if field_name in model_fields - } - - # Add remaining fields - ordered_fields.update( - { - field_name: field - for field_name, field in model_fields.items() - if field_name not in ordered_fields - } - ) - - fields_info = {} - - for field_name, field in ordered_fields.items(): field_info = { "type": field.get_internal_type(), - "required": not field.null and not field.blank, - "is_many_to_one_rel": isinstance(field, ManyToOneRel), - "is_numeric": field.get_internal_type() - in [ - "IntegerField", - "FloatField", - "DecimalField", - "PositiveIntegerField", - "PositiveSmallIntegerField", - "SmallIntegerField", - "BigIntegerField", - ], } - # Handle default values + # Collect default values default_value = None if hasattr(field, "default"): default_value = ( field.default if field.default not in (NOT_PROVIDED, dict) else None ) field_info["default"] = default_value - - # Handle related fields - if field.is_relation: - related_model = field.related_model - if related_model: - related_model_key = ( - f"{related_model._meta.app_label}.{related_model._meta.model_name}" - ) - related_model_info = { - "app_label": related_model._meta.app_label, - "model_name": related_model._meta.model_name, - "model_class_name": related_model.__name__, - "object_type": related_model_key, - "filters": get_field_filters(model_class, field_name), - } - field_info["related_model"] = related_model_info - if ( - related_model.__name__ not in EXCLUDED_MODELS - and related_model not in related_models_to_process - ): - related_models_to_process.append(related_model) - fields_info[field_name] = field_info - return fields_info, related_models_to_process - - -@lru_cache(maxsize=128) -def get_field_filters(model_class, field_name): - """Get filters for a field.""" - if hasattr(model_class, "_netbox_private"): - return None - - try: - filterset_name = f"{model_class.__name__}FilterSet" - filterset_module = importlib.import_module( - f"{model_class._meta.app_label}.filtersets" - ) - filterset_class = getattr(filterset_module, filterset_name) - - _filters = set() - field_filters = [] - for filter_name, filter_instance in filterset_class.get_filters().items(): - filter_by = getattr(filter_instance, "field_name", None) - filter_field_extra = getattr(filter_instance, "extra", None) - - if not filter_name.startswith(field_name) or filter_by.endswith("_id"): - continue - - if filter_by and filter_by not in _filters: - _filters.add(filter_by) - field_filters.append( - { - "filter_by": filter_by, - "filter_to_field_name": ( - filter_field_extra.get("to_field_name", None) - if filter_field_extra - else None - ), - } - ) - return list(field_filters) if field_filters else None - except Exception as e: - logger.error( - f"Error getting field filters for model {model_class.__name__} and field {field_name}: {e}" - ) - return None - + return fields_info @lru_cache(maxsize=128) def get_serializer_for_model(model, prefix=""): """Cached wrapper for NetBox's get_serializer_for_model function.""" return netbox_get_serializer_for_model(model, prefix) - - -def discover_models(root_packages: list[str]) -> list[type[models.Model]]: - """Discovers all model classes in specified root packages.""" - discovered_models = [] - - # Look through all modules that might contain serializers - module_names = [ - "api.serializers", - ] - - for root_package in root_packages: - logger.debug(f"Searching in root package: {root_package}") - - for module_name in module_names: - full_module_path = f"{root_package}.{module_name}" - try: - module = __import__(full_module_path, fromlist=["*"]) - except ImportError: - logger.error(f"Could not import {full_module_path}") - continue - - # Find all serializer classes in the module - for serializer_name in dir(module): - serializer = getattr(module, serializer_name) - if ( - isinstance(serializer, type) - and issubclass(serializer, serializers.Serializer) - and serializer != serializers.Serializer - and serializer != serializers.ModelSerializer - and hasattr(serializer, "Meta") - and hasattr(serializer.Meta, "model") - ): - model = serializer.Meta.model - if model not in discovered_models: - discovered_models.append(model) - logger.debug( - f"Discovered model: {model.__module__}.{model.__name__}" - ) - - return discovered_models diff --git a/netbox_diode_plugin/api/transformer.py b/netbox_diode_plugin/api/transformer.py index d97802d..5accbbf 100644 --- a/netbox_diode_plugin/api/transformer.py +++ b/netbox_diode_plugin/api/transformer.py @@ -95,7 +95,7 @@ def transform_proto_json(proto_json: dict, object_type: str, supported_models: d This also handles placing `_type` fields for generic references, a certain form of deduplication and resolution of existing objects. """ - entities = _transform_proto_json_1(proto_json, object_type) + entities = _transform_proto_json_1(proto_json, object_type, supported_models) entities = _topo_sort(entities) deduplicated = _fingerprint_dedupe(entities) @@ -115,12 +115,13 @@ def transform_proto_json(proto_json: dict, object_type: str, supported_models: d return output -def _transform_proto_json_1(proto_json: dict, object_type: str, context=None) -> list[dict]: # noqa: C901 +def _transform_proto_json_1(proto_json: dict, object_type: str, supported_models: dict, context=None) -> list[dict]: # noqa: C901 uuid = str(uuid4()) node = { "_object_type": object_type, "_uuid": uuid, "_refs": set(), + "_warnings": {}, } # handle camelCase protoJSON if provided... @@ -142,13 +143,27 @@ def _transform_proto_json_1(proto_json: dict, object_type: str, context=None) -> # special handling for custom fields custom_fields = dict.pop(proto_json, "custom_fields", {}) if custom_fields: - custom_fields, custom_fields_refs, nested = _prepare_custom_fields(object_type, custom_fields) + custom_fields, custom_fields_refs, nested = _prepare_custom_fields(object_type, custom_fields, supported_models) node['custom_fields'] = custom_fields node['_refs'].update(custom_fields_refs) nodes += nested + supported_fields = _supported_diode_fields(object_type, supported_models) + def is_supported(field_name, ref_info): + if ref_info is None: + return field_name in supported_fields + if ref_info.object_type not in supported_models: + return False + if ref_info.is_generic: + return ref_info.field_name + "_type" in supported_fields + return ref_info.field_name in supported_fields + for key, value in proto_json.items(): ref_info = get_json_ref_info(object_type, key) + if not is_supported(key, ref_info): + node['_warnings'][key] = ["Ignored unsupported field."] + continue + if ref_info is None: node[key] = copy.deepcopy(value) continue @@ -166,7 +181,7 @@ def _transform_proto_json_1(proto_json: dict, object_type: str, context=None) -> if isinstance(value, list): ref_value = [] for item in value: - nested = _transform_proto_json_1(item, ref_info.object_type, nested_context) + nested = _transform_proto_json_1(item, ref_info.object_type, supported_models, nested_context) nodes += nested ref_uuid = nested[0]['_uuid'] ref_value.append(UnresolvedReference( @@ -175,7 +190,7 @@ def _transform_proto_json_1(proto_json: dict, object_type: str, context=None) -> )) refs.append(ref_uuid) else: - nested = _transform_proto_json_1(value, ref_info.object_type, nested_context) + nested = _transform_proto_json_1(value, ref_info.object_type, supported_models, nested_context) nodes += nested ref_uuid = nested[0]['_uuid'] ref_value = UnresolvedReference( @@ -601,7 +616,7 @@ def _check_unresolved_refs(entities: list[dict]) -> list[str]: ) -def _prepare_custom_fields(object_type: str, custom_fields: dict) -> tuple[dict, set, list]: # noqa: C901 +def _prepare_custom_fields(object_type: str, custom_fields: dict, supported_models: dict) -> tuple[dict, set, list]: # noqa: C901 """Prepare custom fields for transformation.""" out = {} refs = set() @@ -623,7 +638,7 @@ def _prepare_custom_fields(object_type: str, custom_fields: dict) -> tuple[dict, elif value_type == "json": out[key] = _prepare_custom_json(value) elif value_type == "object": - nested = _prepare_custom_ref(value) + nested = _prepare_custom_ref(value, supported_models) ref = nested[0] refs.add(ref['_uuid']) nodes += nested @@ -635,7 +650,7 @@ def _prepare_custom_fields(object_type: str, custom_fields: dict) -> tuple[dict, vals = [] for i, item in enumerate(value): keyname = f"{key}[{i}]" - nested = _prepare_custom_ref(item) + nested = _prepare_custom_ref(item, supported_models) ref = nested[0] refs.add(ref['_uuid']) nodes += nested @@ -672,7 +687,7 @@ def _pop_custom_field_type_and_value(data: dict): return value_type, value -def _prepare_custom_ref(data: dict) -> list[dict]: +def _prepare_custom_ref(data: dict, supported_models: dict) -> list[dict]: if not isinstance(data, dict) or len(data) != 1: raise ValueError("must be a dictionary with a single key") @@ -684,4 +699,21 @@ def _prepare_custom_ref(data: dict) -> list[dict]: raise ValueError(f"{field_name} is not a supported custom field reference type") object_type = ref_info.object_type - return _transform_proto_json_1(value, object_type) + return _transform_proto_json_1(value, object_type, supported_models) + +def _supported_diode_fields(object_type, supported_models: dict) -> list[str]: + """ + Get the supported diode fields for a model. + + This excludes fields that are not supported by the current version of NetBox + that the plugin is installed in. i.e. fields from older or newer versions of + NetBox that are also supported by the plugin. + """ + model = supported_models.get(object_type) + if not model: + raise serializers.ValidationError({ + NON_FIELD_ERRORS: [f"{object_type} is not supported in this version."] + }) + model_fields = set(model.get("fields", {}).keys()) + diode_fields = set(legal_fields(object_type)) + return list(model_fields.intersection(diode_fields)) diff --git a/netbox_diode_plugin/api/views.py b/netbox_diode_plugin/api/views.py index 7df6cd5..9ed9ba6 100644 --- a/netbox_diode_plugin/api/views.py +++ b/netbox_diode_plugin/api/views.py @@ -66,9 +66,13 @@ def post(self, request, *args, **kwargs): """Generate diff for entity.""" try: return self._post(request, *args, **kwargs) + except ChangeSetException as e: + result = ChangeSetResult( + errors=e.errors, + ) + return Response(result.to_dict(), status=result.get_status_code()) except Exception: import traceback - traceback.print_exc() raise @@ -77,12 +81,36 @@ def _post(self, request, *args, **kwargs): object_type = request.data.get("object_type") if not entity: - raise ValidationError("Entity is required") + raise ChangeSetException( + "validation error", + errors={ + "request": { + "entity": ["entity is required"] + } + } + ) if not object_type: - raise ValidationError("Object type is required") + raise ChangeSetException( + "validation error", + errors={ + "request": { + "object_type": ["object_type is required"] + } + } + ) app_label, model_name = object_type.split(".") - model_class = apps.get_model(app_label, model_name) + try: + model_class = apps.get_model(app_label, model_name) + except LookupError: + raise ChangeSetException( + "validation error", + errors={ + "request": { + "object_type": [f"{object_type} is not supported in this version."] + } + } + ) for entity_key in get_valid_entity_keys(model_class.__name__): original_entity_data = entity.get(entity_key) @@ -90,19 +118,16 @@ def _post(self, request, *args, **kwargs): break if original_entity_data is None: - raise ValidationError( - f"No data found for {entity_key} in entity got: {entity.keys()}" - ) - - try: - result = generate_changeset(original_entity_data, object_type) - except ChangeSetException as e: - logger.error(f"Error generating change set: {e}") - result = ChangeSetResult( - errors=e.errors, + raise ChangeSetException( + "validation error", + errors={ + "entity": { + entity_key: [f"No data found in expected entity key, got: {entity.keys()}"] + } + } ) - return Response(result.to_dict(), status=result.get_status_code()) + result = generate_changeset(original_entity_data, object_type) branch_schema_id = request.headers.get("X-NetBox-Branch") # If branch schema ID is provided and branching plugin is installed, get branch name diff --git a/netbox_diode_plugin/management/commands/generate_matching_docs.py b/netbox_diode_plugin/management/commands/generate_matching_docs.py index 5a00fcd..81a287f 100644 --- a/netbox_diode_plugin/management/commands/generate_matching_docs.py +++ b/netbox_diode_plugin/management/commands/generate_matching_docs.py @@ -6,7 +6,7 @@ from django.core.management.base import BaseCommand -from netbox_diode_plugin.api.differ import SUPPORTED_MODELS +from netbox_diode_plugin.api.differ import extract_supported_models from netbox_diode_plugin.api.matcher import _LOGICAL_MATCHERS, get_model_matchers @@ -137,7 +137,7 @@ def analyze_builtin_matchers(self) -> dict[str, list[MatcherInfo]]: """Analyze the builtin matchers and extract documentation information.""" documentation = {} - for object_type, model_info in SUPPORTED_MODELS.items(): + for object_type, model_info in extract_supported_models().items(): model_class = model_info["model"] matchers = get_model_matchers(model_class) matcher_infos = [] diff --git a/netbox_diode_plugin/tests/test_api_apply_change_set.py b/netbox_diode_plugin/tests/test_api_apply_change_set.py index 8f6a6b3..775c870 100644 --- a/netbox_diode_plugin/tests/test_api_apply_change_set.py +++ b/netbox_diode_plugin/tests/test_api_apply_change_set.py @@ -93,11 +93,11 @@ def setUp(self): ) DeviceType.objects.bulk_create(self.device_types) - self.roles = ( - DeviceRole(name="Device Role 1", slug="device-role-1", color="ff0000"), - DeviceRole(name="Device Role 2", slug="device-role-2", color="00ff00"), - ) - DeviceRole.objects.bulk_create(self.roles) + # bulk create is wierd due to mptt + self.roles = [ + DeviceRole.objects.create(name="Device Role 1", slug="device-role-1", color="ff0000"), + DeviceRole.objects.create(name="Device Role 2", slug="device-role-2", color="00ff00"), + ] cluster_type = ClusterType.objects.create( name="Cluster Type 1", slug="cluster-type-1" @@ -729,24 +729,6 @@ def test_change_type_and_object_type_provided_return_400( "Unsupported change type 'None'", _get_error(response, "__all__", "change_type"), ) - # self.assertEqual( - # response.json().get("errors")[0].get("change_type"), - # "This field may not be null.", - # ) - # self.assertEqual( - # response.json().get("errors")[0].get("object_type"), - # "This field may not be blank.", - # ) - - # # Second item of change_set - # self.assertEqual( - # response.json().get("errors")[1].get("change_id"), - # self.get_change_id(payload, 1), - # ) - # self.assertEqual( - # response.json().get("errors")[1].get("change_type"), - # "This field may not be blank.", - # ) def test_create_ip_address_return_200(self): """Test create ip_address with successful.""" @@ -770,163 +752,6 @@ def test_create_ip_address_return_200(self): } _ = self.send_request(payload) - # def test_create_ip_address_return_400(self): - # """Test create ip_address with missing interface name.""" - # payload = { - # "id": str(uuid.uuid4()), - # "change_set": [ - # { - # "change_id": str(uuid.uuid4()), - # "change_type": "create", - # "object_version": None, - # "object_type": "ipam.ipaddress", - # "object_id": None, - # "data": { - # "address": "192.161.3.1/24", - # "assigned_object": { - # "interface": { - # # Forcing to miss the name of the interface - # "device": { - # "name": self.devices[0].name, - # "site": {"name": self.sites[0].name}, - # }, - # }, - # }, - # }, - # }, - # ], - # } - # response = self.send_request(payload, status_code=status.HTTP_400_BAD_REQUEST) - - # self.assertIn( - # "not sufficient to retrieve interface", - # response.json().get("errors")[0].get("assigned_object"), - # ) - - # def test_create_ip_address_not_exist_interface_return_400(self): - # """Test create ip_address with not valid interface.""" - # payload = { - # "id": str(uuid.uuid4()), - # "changes": [ - # { - # "change_id": str(uuid.uuid4()), - # "change_type": "create", - # "object_version": None, - # "object_type": "ipam.ipaddress", - # "object_id": None, - # "data": { - # "address": "192.161.3.1/24", - # "assigned_object": { - # "interface": { - # "name": "not_exist", - # "device": { - # "name": self.devices[0].name, - # "site": {"name": self.sites[0].name}, - # }, - # }, - # }, - # }, - # }, - # ], - # } - # response = self.send_request(payload, status_code=status.HTTP_400_BAD_REQUEST) - - # self.assertIn( - # "does not exist", - # response.json().get("errors")[0].get("assigned_object"), - # ) - - # def test_create_ip_address_missing_device_interface_return_400(self): - # """Test create ip_address with missing device interface name.""" - # payload = { - # "id": str(uuid.uuid4()), - # "changes": [ - # { - # "change_id": str(uuid.uuid4()), - # "change_type": "create", - # "object_version": None, - # "object_type": "ipam.ipaddress", - # "object_id": None, - # "ref_id": "1", - # "data": { - # "address": "192.161.3.1/24", - # "assigned_object": { - # "interface": { - # "name": "not_exist", - # "device": { - # "site": {"name": self.sites[0].name}, - # }, - # }, - # }, - # }, - # }, - # ], - # } - # response = self.send_request(payload, status_code=status.HTTP_400_BAD_REQUEST) - - # self.assertIn( - # "Interface device needs to have either id or name provided", - # response.json().get("errors", {}) # .get("assigned_object"), - # ) - - # def test_create_ip_address_missing_interface_device_site_return_400(self): - # """Test create ip_address with missing interface device site name.""" - # payload = { - # "id": str(uuid.uuid4()), - # "changes": [ - # { - # "change_id": str(uuid.uuid4()), - # "change_type": "create", - # "object_version": None, - # "object_type": "ipam.ipaddress", - # "object_id": None, - # "ref_id": "1", - # "data": { - # "address": "192.161.3.1/24", - # "assigned_object": { - # "interface": { - # "name": "not_exist", - # "device": { - # "name": self.devices[0].name, - # "site": {"facility": "Betha"}, - # }, - # }, - # }, - # }, - # }, - # ], - # } - # response = self.send_request(payload, status_code=status.HTTP_400_BAD_REQUEST) - - # self.assertIn( - # "Interface device site needs to have either id or name provided", - # response.json().get("errors")[0].get("assigned_object"), - # ) - - # def test_primary_ip_address_not_found_return_400(self): - # """Test update primary ip address with site name.""" - # payload = { - # "id": str(uuid.uuid4()), - # "changes": [ - # { - # "change_id": str(uuid.uuid4()), - # "change_type": "update", - # "object_version": None, - # "object_type": "dcim.device", - # "data": { - # "name": self.devices[0].name, - # "site": {"name": self.sites[0].name}, - # "primary_ip6": { - # "address": "2001:DB8:0000:0000:244:17FF:FEB6:D37D/64", - # }, - # }, - # }, - # ], - # } - # response = self.send_request(payload, status_code=status.HTTP_400_BAD_REQUEST) - - # self.assertEqual(response.json()[0], "primary IP not found") - def test_add_primary_ip_address_to_device(self): """Add primary ip address to device.""" payload = { @@ -997,9 +822,10 @@ def test_create_prefix_with_unknown_site_fails(self): ], } response = self.send_request(payload, status_code=status.HTTP_400_BAD_REQUEST) + print(response.json()) self.assertIn( - 'Please select a site.', - _get_error(response, "ipam.prefix", "scope"), + 'Related object not found using the provided value: 99.', + _get_error(response, "ipam.prefix", "scope_id"), ) self.assertFalse(Prefix.objects.filter(prefix="192.168.0.0/24").exists()) diff --git a/netbox_diode_plugin/tests/test_api_diff_and_apply.py b/netbox_diode_plugin/tests/test_api_diff_and_apply.py index d3081ee..fb43cec 100644 --- a/netbox_diode_plugin/tests/test_api_diff_and_apply.py +++ b/netbox_diode_plugin/tests/test_api_diff_and_apply.py @@ -1489,7 +1489,6 @@ def diff_and_apply(self, payload): ) self.assertEqual(response1.status_code, status.HTTP_200_OK) diff = response1.json().get("change_set", {}) - response2 = self.client.post( self.apply_url, data=diff, format="json", **self.authorization_header ) diff --git a/netbox_diode_plugin/tests/test_generate_matching_docs.py b/netbox_diode_plugin/tests/test_generate_matching_docs.py index 0c03dce..30597b7 100644 --- a/netbox_diode_plugin/tests/test_generate_matching_docs.py +++ b/netbox_diode_plugin/tests/test_generate_matching_docs.py @@ -245,7 +245,7 @@ def test_analyze_logical_matchers(self, mock_logical_matchers): self.assertEqual(matcher2_info.version_constraints, "≤4.2.99") self.assertEqual(matcher2_info.matcher_source, "logical") - @mock.patch('netbox_diode_plugin.management.commands.generate_matching_docs.SUPPORTED_MODELS') + @mock.patch('netbox_diode_plugin.management.commands.generate_matching_docs.extract_supported_models') @mock.patch('netbox_diode_plugin.management.commands.generate_matching_docs.get_model_matchers') def test_analyze_builtin_matchers(self, mock_get_model_matchers, mock_supported_models): """Test analyzing builtin matchers.""" @@ -277,9 +277,9 @@ def test_analyze_builtin_matchers(self, mock_get_model_matchers, mock_supported_ mock_logical_matcher.max_version = None # Mock the supported models and get_model_matchers - mock_supported_models.items.return_value = [ - ("dcim.site", {"model": mock_model_class}) - ] + mock_supported_models.return_value = { + "dcim.site": {"model": mock_model_class} + } mock_get_model_matchers.return_value = [ mock_unique_matcher, mock_constraint_matcher, diff --git a/netbox_diode_plugin/tests/test_updates_cases.json b/netbox_diode_plugin/tests/test_updates_cases.json index 5ff0a5a..adb1be0 100644 --- a/netbox_diode_plugin/tests/test_updates_cases.json +++ b/netbox_diode_plugin/tests/test_updates_cases.json @@ -912,7 +912,7 @@ "lookup": {"name": "Contact 1"}, "create_expect": { "name": "Contact 1", - "group.name": "Contact Group 1", + "groups.all.__by_name": ["Contact Group 1"], "description": "Contact 1 Description" }, "create": { @@ -3396,6 +3396,34 @@ "description": "Catalyst 9300 8 x 10GE Network Module Updated" } }, + { + "name": "dcim_moduletypeprofile_1", + "object_type": "dcim.moduletypeprofile", + "lookup": {"name": "Module Type Profile 1"}, + "create_expect": { + "name": "Module Type Profile 1", + "description": "This is a module type profile" + }, + "create": { + "module_type_profile": { + "name": "Module Type Profile 1", + "description": "This is a module type profile", + "schema": "{\"$schema\": \"https://json-schema.org/draft/2020-12/schema\", \"$id\": \"https://example.com/product.schema.json\", \"title\": \"Product\", \"description\": \"A product from Acme's catalog\", \"type\": \"object\", \"properties\": {\"productId\": {\"description\": \"The unique identifier for a product\", \"type\": \"integer\"}}}", + "comments": "This is a module type profile comment" + } + }, + "update": { + "module_type_profile": { + "name": "Module Type Profile 1", + "description": "This is a module type profile updated", + "schema": "{\"$schema\": \"https://json-schema.org/draft/2020-12/schema\", \"$id\": \"https://example.com/product.schema.json\", \"title\": \"Product\", \"description\": \"A product from Acme's catalog\", \"type\": \"object\", \"properties\": {\"productId\": {\"description\": \"The unique identifier for a product\", \"type\": \"integer\"}}}", + "comments": "This is a module type profile comment" + } + }, + "update_expect": { + "description": "This is a module type profile updated" + } + }, { "name": "dcim_platform_1", "object_type": "dcim.platform", @@ -4000,6 +4028,129 @@ "description": "Global MPLS backbone network Updated" } }, + { + "name": "extras_custom_field_1", + "object_type": "extras.customfield", + "lookup": {"name": "wlans"}, + "create_expect": { + "name": "wlans", + "label": "WLANs", + "type": "multiobject" + }, + "create": { + "custom_field": { + "name": "wlans", + "label": "WLANs", + "object_types": ["dcim.device"], + "type": "multiobject", + "related_object_type": "wireless.wirelesslan", + "search_weight": 1000, + "filter_logic": "loose" + } + }, + "update": { + "custom_field": { + "name": "wlans", + "label": "WLANs Served", + "object_types": ["dcim.device"], + "type": "multiobject", + "related_object_type": "wireless.wirelesslan", + "search_weight": 1000, + "filter_logic": "loose" + } + }, + "update_expect": { + "label": "WLANs Served" + } + }, + { + "name": "extras_customfieldchoiceset_1", + "object_type": "extras.customfieldchoiceset", + "lookup": {"name": "scope_choices"}, + "create": { + "custom_field_choice_set": { + "name": "scope_choices", + "extra_choices": ["site:Site", "virtual_machine:Virtual Machine", "toadstool"] + } + }, + "create_expect": { + "name": "scope_choices", + "extra_choices": [["site", "Site"], ["virtual_machine", "Virtual Machine"], ["toadstool", "toadstool"]] + }, + "update": { + "custom_field_choice_set": { + "name": "scope_choices", + "extra_choices": ["site:Site", "virtual_machine:Virtual Machine", "toadstool:Toad Stool"] + } + }, + "update_expect": { + "extra_choices": [["site", "Site"], ["virtual_machine", "Virtual Machine"], ["toadstool", "Toad Stool"]] + } + }, + { + "name": "extras_custom_link_1", + "object_type": "extras.customlink", + "lookup": {"name": "Custom Link 1"}, + "create":{ + "custom_link": { + "name": "Custom Link 1", + "enabled": true, + "link_text": "Custom Link 1", + "link_url": "https://www.google.com", + "object_types": ["dcim.device"] + } + }, + "create_expect": { + "name": "Custom Link 1", + "link_text": "Custom Link 1", + "link_url": "https://www.google.com" + }, + "update":{ + "custom_link": { + "name": "Custom Link 1", + "enabled": true, + "link_text": "Custom Link 1 Updated", + "link_url": "https://www.google.com", + "object_types": ["dcim.device"] + } + }, + "update_expect": { + "link_text": "Custom Link 1 Updated" + } + }, + { + "name": "extras_journal_entry_1", + "object_type": "extras.journalentry", + "lookup": {"comments": "This is a journal entry"}, + "create": { + "journal_entry": { + "assigned_object_site": { + "name": "Journal Test Site" + }, + "comments": "This is a journal entry", + "kind": "info" + } + }, + "create_expect": { + "assigned_object.name": "Journal Test Site", + "comments": "This is a journal entry", + "kind": "info" + }, + "update": { + "journal_entry": { + "assigned_object_site": { + "name": "Journal Test Site" + }, + "comments": "This is a journal entry", + "kind": "danger" + } + }, + "update_expect": { + "assigned_object.name": "Journal Test Site", + "comments": "This is a journal entry", + "kind": "danger" + } + }, { "name": "ipam_rir_1", "object_type": "ipam.rir", @@ -4927,6 +5078,52 @@ "description": "Primary production server network Updated" } }, + { + "name": "ipam_vlan_2", + "object_type": "ipam.vlan", + "lookup": {"vid": 807}, + "create_expect": { + "vid": 807, + "name": "Production Servers", + "site.name": "Site 1" + }, + "create": { + "vlan": { + "vid": "807", + "name": "Production Servers", + "tenant": {"name": "Tenant 1"}, + "status": "active", + "site": {"name": "Site 1"}, + "role": { + "name": "Production", + "slug": "production" + }, + "description": "Primary production server network", + "comments": "Used for customer-facing production workloads", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "vlan": { + "vid": "807", + "name": "Production Servers", + "tenant": {"name": "Tenant 1"}, + "status": "active", + "site": {"name": "Site 1"}, + "role": { + "name": "Production", + "slug": "production" + }, + "description": "Primary production server network Updated", + "comments": "Used for customer-facing production workloads", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Primary production server network Updated", + "site.name": "Site 1" + } + }, { "name": "ipam_vlan_group_1", "object_type": "ipam.vlangroup", @@ -4945,7 +5142,7 @@ "slug": "dc-west", "status": "active" }, - "vid_ranges": [101, 102, 99, 100], + "vid_ranges": ["101", "102", "99", "100"], "description": "Core network VLANs for data center infrastructure", "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] } @@ -4959,7 +5156,7 @@ "slug": "dc-west", "status": "active" }, - "vid_ranges": [101, 102, 99, 100], + "vid_ranges": ["101", "102", "99", "100"], "description": "Core network VLANs for data center infrastructure Updated", "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] } diff --git a/netbox_diode_plugin/tests/v4.2.3/tests/__init__.py b/netbox_diode_plugin/tests/v4.2.3/tests/__init__.py new file mode 100644 index 0000000..fa2c4b7 --- /dev/null +++ b/netbox_diode_plugin/tests/v4.2.3/tests/__init__.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python +# Copyright 2025 NetBox Labs, Inc. +"""Diode NetBox Plugin.""" diff --git a/netbox_diode_plugin/tests/v4.2.3/tests/test_api_apply_change_set.py b/netbox_diode_plugin/tests/v4.2.3/tests/test_api_apply_change_set.py new file mode 100644 index 0000000..9fb5b6b --- /dev/null +++ b/netbox_diode_plugin/tests/v4.2.3/tests/test_api_apply_change_set.py @@ -0,0 +1,932 @@ +#!/usr/bin/env python +# Copyright 2025 NetBox Labs, Inc. +"""Diode NetBox Plugin - Tests.""" + +import uuid +from types import SimpleNamespace +from unittest import mock + +from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Rack, Site +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from ipam.models import ASN, RIR, IPAddress, Prefix +from netaddr import IPNetwork +from rest_framework import status +from utilities.testing import APITestCase +from virtualization.models import Cluster, ClusterType, VirtualMachine + +from netbox_diode_plugin.api.authentication import DiodeOAuth2Authentication +from netbox_diode_plugin.plugin_config import get_diode_user + +User = get_user_model() + +def _get_error(response, object_name, field): + return response.json().get("errors", {}).get(object_name, {}).get(field, []) + +class BaseApplyChangeSet(APITestCase): + """Base ApplyChangeSet test case.""" + + def setUp(self): + """Set up test.""" + self.authorization_header = {"HTTP_AUTHORIZATION": "Bearer mocked_oauth_token"} + self.diode_user = SimpleNamespace( + user = get_diode_user(), + token_scopes=["netbox:read", "netbox:write"], + token_data={"scope": "netbox:read netbox:write"} + ) + + self.introspect_patcher = mock.patch.object( + DiodeOAuth2Authentication, + '_introspect_token', + return_value=self.diode_user + ) + self.introspect_patcher.start() + + rir = RIR.objects.create(name="RFC 6996", is_private=True) + self.asns = [ASN(asn=65000 + i, rir=rir) for i in range(8)] + ASN.objects.bulk_create(self.asns) + + self.sites = ( + Site( + id=10, + name="Site 1", + slug="site-1", + facility="Alpha", + description="First test site", + physical_address="123 Fake St Lincoln NE 68588", + shipping_address="123 Fake St Lincoln NE 68588", + comments="Lorem ipsum etcetera", + ), + Site( + id=20, + name="Site 2", + slug="site-2", + facility="Bravo", + description="Second test site", + physical_address="725 Cyrus Valleys Suite 761 Douglasfort NE 57761", + shipping_address="725 Cyrus Valleys Suite 761 Douglasfort NE 57761", + comments="Lorem ipsum etcetera", + ), + ) + Site.objects.bulk_create(self.sites) + + self.racks = ( + Rack(name="Rack 1", site=self.sites[0]), + Rack(name="Rack 2", site=self.sites[1]), + ) + Rack.objects.bulk_create(self.racks) + + manufacturer = Manufacturer.objects.create( + name="Manufacturer 1", slug="manufacturer-1" + ) + + self.device_types = ( + DeviceType( + manufacturer=manufacturer, model="Device Type 1", slug="device-type-1" + ), + DeviceType( + manufacturer=manufacturer, + model="Device Type 2", + slug="device-type-2", + u_height=2, + ), + ) + DeviceType.objects.bulk_create(self.device_types) + + self.roles = ( + DeviceRole(name="Device Role 1", slug="device-role-1", color="ff0000"), + DeviceRole(name="Device Role 2", slug="device-role-2", color="00ff00"), + ) + DeviceRole.objects.bulk_create(self.roles) + + cluster_type = ClusterType.objects.create( + name="Cluster Type 1", slug="cluster-type-1" + ) + + self.cluster_types = (cluster_type,) + + site_content_type = ContentType.objects.get_for_model(Site) + + self.clusters = ( + Cluster(name="Cluster 1", type=cluster_type, scope_type=site_content_type, scope_id=self.sites[0].id), + Cluster(name="Cluster 2", type=cluster_type, scope_type=site_content_type, scope_id=self.sites[0].id), + ) + Cluster.objects.bulk_create(self.clusters) + + self.devices = ( + Device( + id=10, + device_type=self.device_types[0], + role=self.roles[0], + name="Device 1", + site=self.sites[0], + rack=self.racks[0], + cluster=self.clusters[0], + local_context_data={"A": 1}, + ), + Device( + id=20, + device_type=self.device_types[0], + role=self.roles[0], + name="Device 2", + site=self.sites[0], + rack=self.racks[0], + cluster=self.clusters[0], + local_context_data={"B": 2}, + ), + ) + Device.objects.bulk_create(self.devices) + + self.interfaces = ( + Interface(name="Interface 1", device=self.devices[0], type="1000baset"), + Interface(name="Interface 2", device=self.devices[0], type="1000baset"), + Interface(name="Interface 3", device=self.devices[0], type="1000baset"), + Interface(name="Interface 4", device=self.devices[0], type="1000baset"), + Interface(name="Interface 5", device=self.devices[0], type="1000baset"), + ) + Interface.objects.bulk_create(self.interfaces) + + self.ip_addresses = ( + IPAddress( + address=IPNetwork("10.0.0.1/24"), assigned_object=self.interfaces[0] + ), + IPAddress( + address=IPNetwork("192.0.2.1/24"), assigned_object=self.interfaces[1] + ), + ) + IPAddress.objects.bulk_create(self.ip_addresses) + + self.virtual_machines = ( + VirtualMachine(name="Virtual Machine 1"), + VirtualMachine(name="Virtual Machine 2"), + ) + VirtualMachine.objects.bulk_create(self.virtual_machines) + + self.url = "/netbox/api/plugins/diode/apply-change-set/" + + def tearDown(self): + """Clean up after tests.""" + self.introspect_patcher.stop() + super().tearDown() + + def send_request(self, payload, status_code=status.HTTP_200_OK): + """Post the payload to the url and return the response.""" + response = self.client.post( + self.url, + data=payload, + format="json", + **self.authorization_header + ) + self.assertEqual(response.status_code, status_code) + return response + + +class ApplyChangeSetTestCase(BaseApplyChangeSet): + """ApplyChangeSet test cases.""" + + @staticmethod + def get_change_id(payload, index): + """Get change_id from payload.""" + return payload.get("changes")[index].get("change_id") + + def test_change_type_create_return_200(self): + """Test create change_type with successful.""" + payload = { + "id": str(uuid.uuid4()), + "changes": [ + { + "change_id": str(uuid.uuid4()), + "change_type": "create", + "object_version": None, + "object_type": "dcim.site", + "object_id": None, + "ref_id": "1", + "data": { + "name": "Site A", + "slug": "site-a", + "facility": "Alpha", + "description": "", + "physical_address": "123 Fake St Lincoln NE 68588", + "shipping_address": "123 Fake St Lincoln NE 68588", + "comments": "Lorem ipsum etcetera", + "asns": [self.asns[0].pk, self.asns[1].pk], + }, + }, + { + "change_id": str(uuid.uuid4()), + "change_type": "create", + "object_version": None, + "object_type": "dcim.interface", + "object_id": None, + "ref_id": "2", + "data": { + "name": "Interface 1", + "device": self.devices[1].pk, + "type": "other", + }, + }, + { + "change_id": str(uuid.uuid4()), + "change_type": "create", + "object_version": None, + "object_type": "ipam.ipaddress", + "object_id": None, + "ref_id": "3", + "data": { + "address": "192.163.2.1/24", + "assigned_object_type": "dcim.interface", + "assigned_object_id": self.interfaces[2].pk + }, + }, + ], + } + + _ = self.send_request(payload) + + def test_change_type_update_return_200(self): + """Test update change_type with successful.""" + payload = { + "id": str(uuid.uuid4()), + "changes": [ + { + "change_id": str(uuid.uuid4()), + "change_type": "update", + "object_version": None, + "object_type": "dcim.site", + "object_id": 20, + "data": { + "name": "Site A", + "slug": "site-a", + "facility": "Alpha", + "description": "", + "physical_address": "123 Fake St Lincoln NE 68588", + "shipping_address": "123 Fake St Lincoln NE 68588", + "comments": "Lorem ipsum etcetera", + "asns": [self.asns[0].pk, self.asns[1].pk], + }, + }, + ], + } + + _ = self.client.post( + self.url, payload, format="json", **self.authorization_header + ) + + site_updated = Site.objects.get(id=20) + + self.assertEqual(site_updated.name, "Site A") + + def test_change_type_create_with_error_return_400(self): + """Test create change_type with wrong payload.""" + payload = { + "id": str(uuid.uuid4()), + "changes": [ + { + "change_id": str(uuid.uuid4()), + "change_type": "create", + "object_version": None, + "object_type": "dcim.site", + "object_id": None, + "ref_id": "1", + "data": { + "name": "Site A", + "slug": "site-a", + "facility": "Alpha", + "description": "", + "physical_address": "123 Fake St Lincoln NE 68588", + "shipping_address": "123 Fake St Lincoln NE 68588", + "comments": "Lorem ipsum etcetera", + "asns": 1, + }, + }, + ], + } + + response = self.send_request(payload, status_code=status.HTTP_400_BAD_REQUEST) + site_created = Site.objects.filter(name="Site A") + + self.assertIn( + 'Expected a list of items but got type "int".', + _get_error(response, "dcim.site", "asns"), + ) + self.assertFalse(site_created.exists()) + + def test_change_type_update_with_error_return_400(self): + """Test update change_type with wrong payload.""" + payload = { + "id": str(uuid.uuid4()), + "changes": [ + { + "change_id": str(uuid.uuid4()), + "change_type": "update", + "object_version": None, + "object_type": "dcim.site", + "object_id": 20, + "data": { + "name": "Site A", + "slug": "site-a", + "facility": "Alpha", + "description": "", + "physical_address": "123 Fake St Lincoln NE 68588", + "shipping_address": "123 Fake St Lincoln NE 68588", + "comments": "Lorem ipsum etcetera", + "asns": 1, + }, + }, + ], + } + + response = self.send_request(payload, status_code=status.HTTP_400_BAD_REQUEST) + + site_updated = Site.objects.get(id=20) + self.assertIn( + 'Expected a list of items but got type "int".', + _get_error(response, "dcim.site", "asns") + ) + self.assertEqual(site_updated.name, "Site 2") + + def test_change_type_create_with_multiples_objects_return_200(self): + """Test create change type with two objects.""" + payload = { + "id": str(uuid.uuid4()), + "changes": [ + { + "change_id": str(uuid.uuid4()), + "change_type": "create", + "object_version": None, + "object_type": "dcim.site", + "object_id": None, + "ref_id": "1", + "data": { + "name": "Site Z", + "slug": "site-z", + "facility": "Omega", + "description": "", + "physical_address": "123 Fake St Lincoln NE 68588", + "shipping_address": "123 Fake St Lincoln NE 68588", + "comments": "Lorem ipsum etcetera", + "asns": [self.asns[0].pk, self.asns[1].pk], + }, + }, + { + "change_id": str(uuid.uuid4()), + "change_type": "create", + "object_version": None, + "object_type": "dcim.device", + "object_id": None, + "ref_id": "2", + "data": { + "device_type": self.device_types[1].pk, + "role": self.roles[1].pk, + "name": "Test Device 500", + "site": self.sites[1].pk, + "rack": self.racks[1].pk, + "cluster": self.clusters[1].pk, + }, + }, + ], + } + + _ = self.send_request(payload) + + def test_change_type_update_with_multiples_objects_return_200(self): + """Test update change type with two objects.""" + payload = { + "id": str(uuid.uuid4()), + "changes": [ + { + "change_id": str(uuid.uuid4()), + "change_type": "update", + "object_version": None, + "object_type": "dcim.site", + "object_id": 20, + "data": { + "name": "Site A", + "slug": "site-a", + "facility": "Alpha", + "description": "", + "physical_address": "123 Fake St Lincoln NE 68588", + "shipping_address": "123 Fake St Lincoln NE 68588", + "comments": "Lorem ipsum etcetera", + "asns": [self.asns[0].pk, self.asns[1].pk], + }, + }, + { + "change_id": str(uuid.uuid4()), + "change_type": "update", + "object_version": None, + "object_type": "dcim.device", + "object_id": 10, + "data": { + "device_type": self.device_types[1].pk, + "role": self.roles[1].pk, + "name": "Test Device 3", + "site": self.sites[1].pk, + "rack": self.racks[1].pk, + "cluster": self.clusters[1].pk, + }, + }, + ], + } + + _ = self.send_request(payload) + + site_updated = Site.objects.get(id=20) + device_updated = Device.objects.get(id=10) + + self.assertEqual(site_updated.name, "Site A") + self.assertEqual(device_updated.name, "Test Device 3") + + def test_change_type_create_and_update_with_error_in_one_object_return_400(self): + """Test create and update change type with one object with error.""" + payload = { + "id": str(uuid.uuid4()), + "changes": [ + { + "change_id": str(uuid.uuid4()), + "change_type": "create", + "object_version": None, + "object_type": "dcim.site", + "object_id": None, + "ref_id": "1", + "data": { + "name": "Site Z", + "slug": "site-z", + "facility": "Alpha", + "description": "", + "physical_address": "123 Fake St Lincoln NE 68588", + "shipping_address": "123 Fake St Lincoln NE 68588", + "comments": "Lorem ipsum etcetera", + "asns": [self.asns[0].pk, self.asns[1].pk], + }, + }, + { + "change_id": str(uuid.uuid4()), + "change_type": "update", + "object_version": None, + "object_type": "dcim.device", + "object_id": 10, + "data": { + "device_type": 3, + "role": self.roles[1].pk, + "name": "Test Device 4", + "site": self.sites[1].pk, + "rack": self.racks[1].pk, + "cluster": self.clusters[1].pk, + }, + }, + ], + } + + response = self.send_request(payload, status_code=status.HTTP_400_BAD_REQUEST) + + site_created = Site.objects.filter(name="Site Z") + device_created = Device.objects.filter(name="Test Device 4") + + self.assertIn( + "Related object not found using the provided numeric ID: 3", + _get_error(response, "dcim.device", "device_type"), + ) + self.assertFalse(site_created.exists()) + self.assertFalse(device_created.exists()) + + def test_multiples_create_type_error_in_two_objects_return_400(self): + """Test create with error in two objects.""" + payload = { + "id": str(uuid.uuid4()), + "changes": [ + { + "change_id": str(uuid.uuid4()), + "change_type": "create", + "object_version": None, + "object_type": "dcim.site", + "object_id": None, + "ref_id": "1", + "data": { + "name": "Site Z", + "slug": "site-z", + "facility": "Alpha", + "description": "", + "physical_address": "123 Fake St Lincoln NE 68588", + "shipping_address": "123 Fake St Lincoln NE 68588", + "comments": "Lorem ipsum etcetera", + "asns": [self.asns[0].pk, self.asns[1].pk], + }, + }, + { + "change_id": str(uuid.uuid4()), + "change_type": "create", + "object_version": None, + "object_type": "dcim.device", + "object_id": None, + "ref_id": "2", + "data": { + "device_type": 3, + "role": self.roles[1].pk, + "name": "Test Device 4", + "site": self.sites[1].pk, + "rack": self.racks[1].pk, + "cluster": self.clusters[1].pk, + }, + }, + { + "change_id": str(uuid.uuid4()), + "change_type": "create", + "object_version": None, + "object_type": "dcim.device", + "object_id": None, + "ref_id": "3", + "data": { + "device_type": 100, + "role": 10, + "name": "Test Device 40", + "site": self.sites[1].pk, + "rack": self.racks[1].pk, + "cluster": self.clusters[1].pk, + }, + }, + ], + } + + response = self.send_request(payload, status_code=status.HTTP_400_BAD_REQUEST) + + site_created = Site.objects.filter(name="Site Z") + device_created = Device.objects.filter(name="Test Device 4") + + self.assertIn( + "Related object not found using the provided numeric ID: 3", + _get_error(response, "dcim.device", "device_type"), + ) + + self.assertFalse(site_created.exists()) + self.assertFalse(device_created.exists()) + + def test_change_type_update_with_object_id_not_exist_return_400(self): + """Test update object with nonexistent object_id.""" + payload = { + "id": str(uuid.uuid4()), + "changes": [ + { + "change_id": str(uuid.uuid4()), + "change_type": "update", + "object_version": None, + "object_type": "dcim.site", + "object_id": 30, + "data": { + "name": "Site A", + "slug": "site-a", + "facility": "Alpha", + "description": "", + "physical_address": "123 Fake St Lincoln NE 68588", + "shipping_address": "123 Fake St Lincoln NE 68588", + "comments": "Lorem ipsum etcetera", + "asns": 1, + }, + }, + ], + } + + response = self.client.post( + self.url, payload, format="json", **self.authorization_header + ) + + site_updated = Site.objects.get(id=20) + + self.assertIn( + "dcim.site with id 30 does not exist", + _get_error(response, "dcim.site", "object_id"), + ) + self.assertEqual(site_updated.name, "Site 2") + + def test_change_set_id_field_not_provided_return_400(self): + """Test update object with change_set_id incorrect.""" + payload = { + "id": None, + "changes": [ + { + "change_id": str(uuid.uuid4()), + "change_type": "update", + "object_version": None, + "object_type": "dcim.site", + "object_id": 20, + "data": { + "name": "Site A", + "slug": "site-a", + "facility": "Alpha", + "description": "", + "physical_address": "123 Fake St Lincoln NE 68588", + "shipping_address": "123 Fake St Lincoln NE 68588", + "comments": "Lorem ipsum etcetera", + "asns": 1, + }, + }, + ], + } + + response = self.send_request(payload, status_code=status.HTTP_400_BAD_REQUEST) + + self.assertIsNone(response.json().get("errors", {}).get("change_id", None)) + self.assertIn( + "Change set ID is required", + _get_error(response, "changeset", "id"), + ) + + def test_change_type_field_not_provided_return_400( + self, + ): + """Test update object with change_type incorrect.""" + payload = { + "id": str(uuid.uuid4()), + "changes": [ + { + "change_id": str(uuid.uuid4()), + "change_type": "", + "object_version": None, + "object_type": "dcim.site", + "object_id": 20, + "data": { + "name": "Site A", + "slug": "site-a", + "facility": "Alpha", + "description": "", + "physical_address": "123 Fake St Lincoln NE 68588", + "shipping_address": "123 Fake St Lincoln NE 68588", + "comments": "Lorem ipsum etcetera", + "asns": 1, + }, + }, + ], + } + + response = self.send_request(payload, status_code=status.HTTP_400_BAD_REQUEST) + + self.assertIn( + "Unsupported change type ''", + _get_error(response, "dcim.site", "change_type"), + ) + + def test_change_set_id_field_and_change_set_not_provided_return_400(self): + """Test update object with change_set_id and change_set incorrect.""" + payload = { + "id": "", + "changes": [], + } + + response = self.send_request(payload, status_code=status.HTTP_400_BAD_REQUEST) + + self.assertIn( + "Change set ID is required", + _get_error(response, "changeset", "id"), + ) + + def test_change_type_and_object_type_provided_return_400( + self, + ): + """Test change_type and object_type incorrect.""" + payload = { + "id": str(uuid.uuid4()), + "changes": [ + { + "change_id": str(uuid.uuid4()), + "change_type": None, + "object_version": None, + "object_type": "", + "object_id": None, + "ref_id": "1", + "data": { + "name": "Site A", + "slug": "site-a", + "facility": "Alpha", + "description": "", + "physical_address": "123 Fake St Lincoln NE 68588", + "shipping_address": "123 Fake St Lincoln NE 68588", + "comments": "Lorem ipsum etcetera", + }, + }, + { + "change_id": str(uuid.uuid4()), + "change_type": "", + "object_version": None, + "object_type": "dcim.site", + "object_id": None, + "ref_id": "2", + "data": { + "name": "Site Z", + "slug": "site-z", + "facility": "Betha", + "description": "", + "physical_address": "123 Fake St Lincoln NE 68588", + "shipping_address": "123 Fake St Lincoln NE 68588", + "comments": "Lorem ipsum etcetera", + }, + }, + ], + } + + response = self.send_request(payload, status_code=status.HTTP_400_BAD_REQUEST) + + self.assertIn( + "Unsupported change type 'None'", + _get_error(response, "__all__", "change_type"), + ) + + def test_create_ip_address_return_200(self): + """Test create ip_address with successful.""" + payload = { + "id": str(uuid.uuid4()), + "changes": [ + { + "change_id": str(uuid.uuid4()), + "change_type": "create", + "object_version": None, + "object_type": "ipam.ipaddress", + "object_id": None, + "ref_id": "1", + "data": { + "address": "192.161.3.1/24", + "assigned_object_id": self.interfaces[3].pk, + "assigned_object_type": "dcim.interface", + }, + }, + ], + } + _ = self.send_request(payload) + + def test_add_primary_ip_address_to_device(self): + """Add primary ip address to device.""" + payload = { + "id": str(uuid.uuid4()), + "changes": [ + { + "change_id": str(uuid.uuid4()), + "change_type": "update", + "object_version": None, + "object_type": "dcim.device", + "object_id": self.devices[0].pk, + "data": { + "name": self.devices[0].name, + "site": {"name": self.sites[0].name}, + "primary_ip4": self.ip_addresses[0].pk + }, + }, + ], + } + + _ = self.send_request(payload) + device_updated = Device.objects.get(id=10) + + self.assertEqual(device_updated.name, self.devices[0].name) + self.assertEqual(device_updated.primary_ip4, self.ip_addresses[0]) + + def test_create_prefix_with_site_stored_as_scope(self): + """Test create prefix with site stored as scope.""" + payload = { + "id": str(uuid.uuid4()), + "changes": [ + { + "change_id": str(uuid.uuid4()), + "change_type": "create", + "object_version": None, + "object_type": "ipam.prefix", + "object_id": None, + "ref_id": "1", + "data": { + "prefix": "192.168.0.0/24", + "scope_id": self.sites[0].pk, + "scope_type": "dcim.site", + }, + }, + ], + } + _ = self.send_request(payload) + self.assertEqual(Prefix.objects.get(prefix="192.168.0.0/24").scope, self.sites[0]) + + def test_create_prefix_with_unknown_site_fails(self): + """Test create prefix with unknown site fails.""" + payload = { + "id": str(uuid.uuid4()), + "changes": [ + { + "change_id": str(uuid.uuid4()), + "change_type": "create", + "object_version": None, + "object_type": "ipam.prefix", + "object_id": None, + "ref_id": "1", + "data": { + "prefix": "192.168.0.0/24", + "scope_id": 99, + "scope_type": "dcim.site", + }, + }, + ], + } + response = self.send_request(payload, status_code=status.HTTP_400_BAD_REQUEST) + self.assertIn( + 'Please select a site.', + _get_error(response, "ipam.prefix", "scope"), + ) + self.assertFalse(Prefix.objects.filter(prefix="192.168.0.0/24").exists()) + + def test_create_virtualization_cluster_with_site_stored_as_scope(self): + """Test create cluster with site stored as scope.""" + payload = { + "id": str(uuid.uuid4()), + "changes": [ + { + "change_id": str(uuid.uuid4()), + "change_type": "create", + "object_version": None, + "object_type": "virtualization.cluster", + "object_id": None, + "ref_id": "1", + "data": { + "name": "Cluster 3", + "type": { + "name": self.cluster_types[0].name, + }, + "scope_id": self.sites[0].pk, + "scope_type": "dcim.site", + }, + }, + ], + } + _ = self.send_request(payload) + self.assertEqual(Cluster.objects.get(name="Cluster 3").scope, self.sites[0]) + + def test_create_virtualmachine_with_cluster_site_stored_as_scope(self): + """Test create virtualmachine with cluster site stored as scope.""" + payload = { + "id": str(uuid.uuid4()), + "changes": [ + { + "change_id": str(uuid.uuid4()), + "change_type": "update", + "object_version": None, + "object_type": "virtualization.cluster", + "object_id": self.clusters[0].pk, + "data": { + "scope_id": self.sites[0].pk, + "scope_type": "dcim.site", + }, + }, + { + "change_id": str(uuid.uuid4()), + "change_type": "create", + "object_version": None, + "object_type": "virtualization.virtualmachine", + "object_id": None, + "ref_id": "1", + "data": { + "name": "VM foobar", + "site": self.sites[0].pk, + "cluster": self.clusters[0].pk + }, + }, + ], + } + _ = self.send_request(payload) + self.assertEqual(VirtualMachine.objects.get(name="VM foobar", site_id=self.sites[0].id).cluster.scope, self.sites[0]) + + def test_apply_two_changes_that_create_the_same_object_return_200(self): + """Test apply two changes that create the same object return 200.""" + site_name = uuid.uuid4() + payload1 = { + "id": str(uuid.uuid4()), + "changes": [ + { + "change_id": str(uuid.uuid4()), + "change_type": "create", + "object_version": None, + "object_type": "dcim.site", + "object_id": None, + "ref_id": "1", + "data": { + "name": f"Site {site_name}", + "slug": f"site-{site_name}", + "comments": "comment 1", + }, + }, + ], + } + _ = self.send_request(payload1) + + payload2 = { + "id": str(uuid.uuid4()), + "changes": [ + { + "change_id": str(uuid.uuid4()), + "change_type": "create", + "object_version": None, + "object_type": "dcim.site", + "object_id": None, + "ref_id": "1", + "data": { + "name": f"Site {site_name}", + "slug": f"site-{site_name}", + "comments": "comment 1", + }, + }, + ], + } + _ = self.send_request(payload2) diff --git a/netbox_diode_plugin/tests/v4.2.3/tests/test_api_diff_and_apply.py b/netbox_diode_plugin/tests/v4.2.3/tests/test_api_diff_and_apply.py new file mode 100644 index 0000000..d3081ee --- /dev/null +++ b/netbox_diode_plugin/tests/v4.2.3/tests/test_api_diff_and_apply.py @@ -0,0 +1,1497 @@ +#!/usr/bin/env python +# Copyright 2025 NetBox Labs, Inc. +"""Diode NetBox Plugin - Tests.""" + +import copy +import datetime +import decimal +import logging +from types import SimpleNamespace +from unittest import mock +from uuid import uuid4 + +import netaddr +from circuits.models import Circuit, Provider +from core.models import ObjectType +from dcim.models import Device, Interface, ModuleBay, Site +from extras.models import CustomField +from extras.models.customfields import CustomFieldChoiceSet, CustomFieldChoiceSetBaseChoices, CustomFieldTypeChoices +from ipam.models import IPAddress, VLANGroup +from rest_framework import status +from utilities.testing import APITestCase +from virtualization.models import Cluster, VMInterface + +from netbox_diode_plugin.api.authentication import DiodeOAuth2Authentication +from netbox_diode_plugin.plugin_config import get_diode_user + +logger = logging.getLogger(__name__) + +def _get_error(response, object_name, field): + return response.json().get("errors", {}).get(object_name, {}).get(field, []) + +class GenerateDiffAndApplyTestCase(APITestCase): + """GenerateDiff -> ApplyChangeSet test cases.""" + + def setUp(self): + """Set up the test case.""" + self.diff_url = "/netbox/api/plugins/diode/generate-diff/" + self.apply_url = "/netbox/api/plugins/diode/apply-change-set/" + + self.authorization_header = {"HTTP_AUTHORIZATION": "Bearer mocked_oauth_token"} + self.diode_user = SimpleNamespace( + user = get_diode_user(), + token_scopes=["netbox:read", "netbox:write"], + token_data={"scope": "netbox:read netbox:write"} + ) + + self.introspect_patcher = mock.patch.object( + DiodeOAuth2Authentication, + '_introspect_token', + return_value=self.diode_user + ) + self.introspect_patcher.start() + + self.object_type = ObjectType.objects.get_for_model(Site) + + self.uuid_field = CustomField.objects.create( + name='myuuid', + type=CustomFieldTypeChoices.TYPE_TEXT, + required=False, + unique=True, + ) + self.uuid_field.object_types.set([self.object_type]) + self.uuid_field.save() + + self.json_field = CustomField.objects.create( + name='some_json', + type=CustomFieldTypeChoices.TYPE_JSON, + required=False, + unique=False, + ) + self.json_field.object_types.set([self.object_type]) + self.json_field.save() + + self.datetime_field = CustomField.objects.create( + name='mydatetime', + type=CustomFieldTypeChoices.TYPE_DATETIME, + required=False, + unique=False, + ) + self.datetime_field.object_types.set([self.object_type]) + self.datetime_field.save() + + self.date_field = CustomField.objects.create( + name='mydate', + type=CustomFieldTypeChoices.TYPE_DATE, + required=False, + unique=False, + ) + self.date_field.object_types.set([self.object_type]) + self.date_field.save() + + self.decimal_field = CustomField.objects.create( + name='mydecimal', + type=CustomFieldTypeChoices.TYPE_DECIMAL, + required=False, + unique=False, + ) + self.decimal_field.object_types.set([self.object_type]) + self.decimal_field.save() + + self.long_text_field = CustomField.objects.create( + name='my_long_text', + type=CustomFieldTypeChoices.TYPE_LONGTEXT, + required=False, + unique=False, + ) + self.long_text_field.object_types.set([self.object_type]) + self.long_text_field.save() + + choices = CustomFieldChoiceSet.objects.create( + name='my_choices', + base_choices=CustomFieldChoiceSetBaseChoices.IATA, + ) + self.selection_field = CustomField.objects.create( + name='my_selection', + type=CustomFieldTypeChoices.TYPE_SELECT, + required=False, + unique=False, + choice_set=choices, + ) + self.selection_field.object_types.set([self.object_type]) + self.selection_field.save() + + self.multiple_selection_field = CustomField.objects.create( + name='my_multiple_selection', + type=CustomFieldTypeChoices.TYPE_MULTISELECT, + required=False, + unique=False, + choice_set=choices, + ) + self.multiple_selection_field.object_types.set([self.object_type]) + self.multiple_selection_field.save() + + self.object_field = CustomField.objects.create( + name='my_object', + type=CustomFieldTypeChoices.TYPE_OBJECT, + required=False, + unique=False, + related_object_type=self.object_type, + ) + self.object_field.object_types.set([self.object_type]) + self.object_field.save() + + self.multiple_objects_field = CustomField.objects.create( + name='my_multiple_objects', + type=CustomFieldTypeChoices.TYPE_MULTIOBJECT, + required=False, + unique=False, + related_object_type=self.object_type, + ) + self.multiple_objects_field.object_types.set([self.object_type]) + self.multiple_objects_field.save() + + def tearDown(self): + """Clean up after tests.""" + self.introspect_patcher.stop() + super().tearDown() + + def test_generate_diff_and_apply_create_interface_with_tags(self): + """Test generate diff and apply create interface with tags.""" + interface_uuid = str(uuid4()) + payload = { + "timestamp": 1, + "object_type": "dcim.interface", + "entity": { + "interface": { + "name": f"Interface {interface_uuid}", + "mtu": "1500", + "mode": "access", + "tags": [ + {"name": "tag 1"} + ], + "type": "1000base-t", + "device": { + "name": f"Device {uuid4()}", + "device_type": { + "model": f"Device Type {uuid4()}", + "manufacturer": { + "name": f"Manufacturer {uuid4()}" + } + }, + "role": { + "name": f"Role {uuid4()}" + }, + "site": { + "name": f"Site {uuid4()}" + } + }, + "enabled": True, + "description": "Physical interface" + } + } + } + _, response = self.diff_and_apply(payload) + new_interface = Interface.objects.get(name=f"Interface {interface_uuid}") + self.assertEqual(new_interface.tags.count(), 1) + self.assertEqual(new_interface.tags.first().name, "tag 1") + + + def test_generate_diff_and_apply_create_and_update_device_role(self): + """Test generate diff and apply create and update device role.""" + device_uuid = str(uuid4()) + role_1_uuid = str(uuid4()) + role_2_uuid = str(uuid4()) + site_uuid = str(uuid4()) + payload = { + "timestamp": 1, + "object_type": "dcim.device", + "entity": { + "device": { + "name": f"Device {device_uuid}", + "device_type": { + "model": f"Device Type {uuid4()}", + "manufacturer": { + "name": f"Manufacturer {uuid4()}" + } + }, + "role": { + "name": f"Role {role_1_uuid}" + }, + "site": { + "name": f"Site {site_uuid}" + } + }, + } + } + _, response = self.diff_and_apply(payload) + new_device = Device.objects.get(name=f"Device {device_uuid}") + self.assertEqual(new_device.site.name, f"Site {site_uuid}") + self.assertEqual(new_device.role.name, f"Role {role_1_uuid}") + payload = { + "timestamp": 1, + "object_type": "dcim.device", + "entity": { + "device": { + "name": f"Device {device_uuid}", + "deviceType": { + "model": f"Device Type {uuid4()}", + "manufacturer": { + "name": f"Manufacturer {uuid4()}" + } + }, + "role": { + "name": f"Role {role_2_uuid}" + }, + "site": { + "name": f"Site {site_uuid}" + } + }, + } + } + _, response = self.diff_and_apply(payload) + device = Device.objects.get(name=f"Device {device_uuid}") + self.assertEqual(device.site.name, f"Site {site_uuid}") + self.assertEqual(device.role.name, f"Role {role_2_uuid}") + + + def test_generate_diff_and_apply_create_site_autoslug(self): + """Test generate diff and apply create site.""" + """Test generate diff create site.""" + site_uuid = str(uuid4()) + payload = { + "timestamp": 1, + "object_type": "dcim.site", + "entity": { + "site": { + "name": f"Site {site_uuid}", + }, + } + } + + _, response = self.diff_and_apply(payload) + new_site = Site.objects.get(name=f"Site {site_uuid}") + self.assertEqual(new_site.slug, f"site-{site_uuid}") + + def test_generate_diff_and_apply_tags_merged(self): + """Test generate diff and apply merges tags.""" + site_uuid = str(uuid4()) + payload = { + "timestamp": 1, + "object_type": "dcim.site", + "entity": { + "site": { + "name": f"Site {site_uuid}", + "tags": [ + {"name": "tag 1"}, + {"name": "tag 2"}, + ], + }, + } + } + + _, response = self.diff_and_apply(payload) + new_site = Site.objects.get(name=f"Site {site_uuid}") + self.assertEqual(new_site.tags.count(), 2) + tag_names = [tag.name for tag in new_site.tags.all()] + self.assertIn("tag 1", tag_names) + self.assertIn("tag 2", tag_names) + + payload = { + "timestamp": 1, + "object_type": "dcim.site", + "entity": { + "site": { + "name": f"Site {site_uuid}", + "tags": [ + {"name": "tag 3"}, + ], + }, + } + } + + _, response = self.diff_and_apply(payload) + new_site = Site.objects.get(name=f"Site {site_uuid}") + self.assertEqual(new_site.tags.count(), 3) + tag_names = [tag.name for tag in new_site.tags.all()] + self.assertIn("tag 1", tag_names) + self.assertIn("tag 2", tag_names) + self.assertIn("tag 3", tag_names) + + def test_generate_diff_and_apply_refs_not_merged(self): + """Test generate diff and apply does not merge reference lists.""" + site_uuid = str(uuid4()) + payload = { + "timestamp": 1, + "object_type": "dcim.site", + "entity": { + "site": { + "name": f"Site {site_uuid}", + "asns": [ + {"asn": "1", "rir": {"name": "RIR 1"}}, + {"asn": "2", "rir": {"name": "RIR 1"}}, + ], + }, + } + } + + _, response = self.diff_and_apply(payload) + new_site = Site.objects.get(name=f"Site {site_uuid}") + self.assertEqual(new_site.asns.count(), 2) + asns = [asn.asn for asn in new_site.asns.all()] + self.assertIn(1, asns) + self.assertIn(2, asns) + + payload = { + "timestamp": 1, + "object_type": "dcim.site", + "entity": { + "site": { + "name": f"Site {site_uuid}", + "asns": [ + {"asn": "3", "rir": {"name": "RIR 1"}}, + ], + }, + } + } + + _, response = self.diff_and_apply(payload) + new_site = Site.objects.get(name=f"Site {site_uuid}") + self.assertEqual(new_site.asns.count(), 1) + asns = [asn.asn for asn in new_site.asns.all()] + self.assertNotIn(1, asns) + self.assertNotIn(2, asns) + self.assertIn(3, asns) + + + def test_generate_diff_and_apply_create_interface_with_primay_mac_address(self): + """Test generate diff and apply create interface with primary mac address.""" + interface_uuid = str(uuid4()) + payload = { + "timestamp": 1, + "object_type": "dcim.interface", + "entity": { + "interface": { + "name": f"Interface {interface_uuid}", + "type": "1000base-t", + "device": { + "name": f"Device {uuid4()}", + "role": { + "Name": f"Role {uuid4()}", + }, + "site": { + "Name": f"Site {uuid4()}", + }, + "device_type": { + "manufacturer": { + "Name": f"Manufacturer {uuid4()}", + }, + "model": f"Device Type {uuid4()}", + }, + }, + "primary_mac_address": { + "mac_address": "00:00:00:00:00:01", + }, + }, + } + } + + _, response = self.diff_and_apply(payload) + new_interface = Interface.objects.get(name=f"Interface {interface_uuid}") + self.assertEqual(new_interface.primary_mac_address.mac_address, "00:00:00:00:00:01") + + def test_generate_diff_and_apply_create_device_with_primary_ip4_camel_case(self): + """Test generate diff and apply create device with primary ip4 (camel case).""" + device_uuid = str(uuid4()) + interface_uuid = str(uuid4()) + addr = "192.168.1.1" + payload = { + "timestamp": 1, + "object_type": "ipam.ipaddress", + "entity": { + "ipAddress": { + "address": addr, + "assignedObjectInterface": { + "name": f"Interface {interface_uuid}", + "type": "1000base-t", + "device": { + "name": f"Device {device_uuid}", + "role": { + "name": f"Role {uuid4()}", + }, + "site": { + "name": f"Site {uuid4()}", + }, + "deviceType": { + "manufacturer": { + "name": f"Manufacturer {uuid4()}", + }, + "model": f"Device Type {uuid4()}", + }, + "primaryIp4": { + "address": addr, + }, + }, + }, + }, + }, + } + + _, response = self.diff_and_apply(payload) + new_ipaddress = IPAddress.objects.get(address=addr) + self.assertEqual(new_ipaddress.assigned_object.name, f"Interface {interface_uuid}") + device = Device.objects.get(name=f"Device {device_uuid}") + self.assertEqual(device.primary_ip4.pk, new_ipaddress.pk) + + def test_generate_diff_and_apply_create_device_with_primary_ip4(self): + """Test generate diff and apply create device with primary ip4.""" + device_uuid = str(uuid4()) + interface_uuid = str(uuid4()) + addr = "192.168.1.1" + payload = { + "timestamp": 1, + "object_type": "ipam.ipaddress", + "entity": { + "ip_address": { + "address": addr, + "assigned_object_interface": { + "name": f"Interface {interface_uuid}", + "type": "1000base-t", + "device": { + "name": f"Device {device_uuid}", + "role": { + "name": f"Role {uuid4()}", + }, + "site": { + "name": f"Site {uuid4()}", + }, + "device_type": { + "manufacturer": { + "name": f"Manufacturer {uuid4()}", + }, + "model": f"Device Type {uuid4()}", + }, + "primary_ip4": { + "address": addr, + }, + }, + }, + }, + }, + } + + _, response = self.diff_and_apply(payload) + new_ipaddress = IPAddress.objects.get(address=addr) + self.assertEqual(new_ipaddress.assigned_object.name, f"Interface {interface_uuid}") + device = Device.objects.get(name=f"Device {device_uuid}") + self.assertEqual(device.primary_ip4.pk, new_ipaddress.pk) + + def test_generate_diff_and_apply_create_device_with_primary_ip6(self): + """Test generate diff and apply create device with primary ip6.""" + device_uuid = str(uuid4()) + interface_uuid = str(uuid4()) + addr = "2001:db8::1" + payload = { + "timestamp": 1, + "object_type": "ipam.ipaddress", + "entity": { + "ip_address": { + "address": addr, + "assigned_object_interface": { + "name": f"Interface {interface_uuid}", + "type": "1000base-t", + "device": { + "name": f"Device {device_uuid}", + "role": { + "name": f"Role {uuid4()}", + }, + "site": { + "name": f"Site {uuid4()}", + }, + "device_type": { + "manufacturer": { + "name": f"Manufacturer {uuid4()}", + }, + "model": f"Device Type {uuid4()}", + }, + "primary_ip6": { + "address": addr, + }, + }, + }, + }, + }, + } + + _, response = self.diff_and_apply(payload) + new_ipaddress = IPAddress.objects.get(address=addr) + self.assertEqual(new_ipaddress.assigned_object.name, f"Interface {interface_uuid}") + device = Device.objects.get(name=f"Device {device_uuid}") + self.assertEqual(device.primary_ip6.pk, new_ipaddress.pk) + + def test_generate_diff_and_apply_create_device_with_oob_ip(self): + """Test generate diff and apply create device with oob ip.""" + device_uuid = str(uuid4()) + interface_uuid = str(uuid4()) + addr = "192.168.1.1/24" + payload = { + "timestamp": 1, + "object_type": "ipam.ipaddress", + "entity": { + "ip_address": { + "address": addr, + "assigned_object_interface": { + "name": f"Interface {interface_uuid}", + "type": "1000base-t", + "device": { + "name": f"Device {device_uuid}", + "role": { + "name": f"Role {uuid4()}", + }, + "site": { + "name": f"Site {uuid4()}", + }, + "device_type": { + "manufacturer": { + "name": f"Manufacturer {uuid4()}", + }, + "model": f"Device Type {uuid4()}", + }, + "oob_ip": { + "address": addr, + }, + }, + }, + }, + }, + } + + _, response = self.diff_and_apply(payload) + new_ipaddress = IPAddress.objects.get(address=addr) + self.assertEqual(new_ipaddress.assigned_object.name, f"Interface {interface_uuid}") + device = Device.objects.get(name=f"Device {device_uuid}") + self.assertEqual(device.oob_ip.pk, new_ipaddress.pk) + + def test_generate_diff_and_apply_create_and_update_site_with_custom_field(self): + """Test generate diff and apply create and update site with custom field.""" + site_uuid = str(uuid4()) + payload = { + "timestamp": 1, + "object_type": "dcim.site", + "entity": { + "site": { + "name": "A New Custom Site", + "slug": "a-new-custom-site", + "custom_fields": { + "myuuid": { + "text": site_uuid, + }, + "mydecimal": { + "decimal": 1234.567, + }, + "some_json": { + "json": '{"some_key": 9876543210}', + }, + "my_long_text": { + "long_text": "This is a long text", + }, + "my_selection": { + "selection": "LAX", + }, + "my_multiple_selection": { + "multiple_selection": ["JFK", "LAX"], + }, + "my_object": { + "object": { + "site": { + "name": "Custom Object Site Ref 1", + } + }, + }, + "my_multiple_objects": { + "multiple_objects": [ + { + "site": { + "name": "Custom Object Site Ref 2", + } + }, + { + "site": { + "name": "Custom Object Site Ref 3", + } + }, + ], + }, + }, + }, + } + } + + _, response = self.diff_and_apply(payload) + new_site = Site.objects.get(name="A New Custom Site") + self.assertEqual(new_site.custom_field_data[self.uuid_field.name], site_uuid) + self.assertEqual(new_site.custom_field_data[self.json_field.name], {"some_key": 9876543210}) + self.assertEqual(new_site.custom_field_data[self.decimal_field.name], 1234.567) + self.assertEqual(new_site.custom_field_data[self.long_text_field.name], "This is a long text") + self.assertEqual(new_site.custom_field_data[self.selection_field.name], "LAX") + self.assertEqual(new_site.custom_field_data[self.multiple_selection_field.name], ["JFK", "LAX"]) + + siteRef1 = Site.objects.get(name="Custom Object Site Ref 1") + self.assertIsNotNone(siteRef1) + self.assertEqual(new_site.custom_field_data[self.object_field.name], siteRef1.pk) + siteRef2 = Site.objects.get(name="Custom Object Site Ref 2") + self.assertIsNotNone(siteRef2) + self.assertEqual(new_site.custom_field_data[self.multiple_objects_field.name][0], siteRef2.pk) + siteRef3 = Site.objects.get(name="Custom Object Site Ref 3") + self.assertIsNotNone(siteRef3) + self.assertEqual(new_site.custom_field_data[self.multiple_objects_field.name][1], siteRef3.pk) + + payload = { + "timestamp": 1, + "object_type": "dcim.site", + "entity": { + "site": { + "comments": "An updated comment", + "custom_fields": { + "myuuid": { + "text": site_uuid, + }, + "some_json": { + "json": '{"some_key": 1234567890}', + }, + "mydatetime": { + "datetime": "2026-01-01T09:00:00Z", + }, + "mydate": { + "date": "2026-01-01T00:00:00Z", + }, + }, + }, + } + } + + _, response = self.diff_and_apply(payload) + new_site = Site.objects.get(name="A New Custom Site") + self.assertEqual(new_site.cf[self.uuid_field.name], site_uuid) + self.assertEqual(new_site.cf[self.json_field.name], {"some_key": 1234567890}) + self.assertEqual(new_site.cf[self.datetime_field.name], datetime.datetime(2026, 1, 1, 9, 0, 0, tzinfo=datetime.timezone.utc)) + self.assertEqual(new_site.cf[self.date_field.name], datetime.date(2026, 1, 1)) + + payload = { + "timestamp": 1, + "object_type": "dcim.site", + "entity": { + "site": { + "custom_fields": { + "myuuid": { + "text": site_uuid, + }, + "mydatetime": { + "datetime": "2026-01-01T10:00:00Z", + }, + "mydate": { + "date": "2026-01-02T00:00:00Z", + }, + "my_multiple_objects": { + "multiple_objects": [ + { + "site": { + "name": "Custom Object Site Ref 2", + } + }, + { + "site": { + "name": "Custom Object Site Ref 4", + } + }, + ], + }, + + }, + }, + } + } + + _, response = self.diff_and_apply(payload) + new_site = Site.objects.get(name="A New Custom Site") + self.assertEqual(new_site.cf[self.uuid_field.name], site_uuid) + self.assertEqual(new_site.cf[self.json_field.name], {"some_key": 1234567890}) + self.assertEqual(new_site.cf[self.datetime_field.name], datetime.datetime(2026, 1, 1, 10, 0, 0, tzinfo=datetime.timezone.utc)) + self.assertEqual(new_site.cf[self.date_field.name], datetime.date(2026, 1, 2)) + + self.assertEqual(len(new_site.custom_field_data[self.multiple_objects_field.name]), 2) + siteRef2 = Site.objects.get(name="Custom Object Site Ref 2") + self.assertIsNotNone(siteRef2) + self.assertEqual(new_site.custom_field_data[self.multiple_objects_field.name][0], siteRef2.pk) + siteRef4 = Site.objects.get(name="Custom Object Site Ref 4") + self.assertIsNotNone(siteRef3) + self.assertEqual(new_site.custom_field_data[self.multiple_objects_field.name][1], siteRef4.pk) + + + payload = { + "timestamp": 1, + "object_type": "dcim.site", + "entity": { + "site": { + "custom_fields": { + "myuuid": { + "text": site_uuid, + }, + "mydatetime": { + "datetime": "2026-01-01T10:00:00Z", + }, + "mydate": { + "date": "2026-01-02T00:00:00Z", + }, + }, + }, + } + } + response1 = self.client.post( + self.diff_url, data=payload, format="json", **self.authorization_header + ) + self.assertEqual(response1.status_code, status.HTTP_200_OK) + diff = response1.json().get("change_set", {}) + self.assertEqual(diff.get("changes", []), []) + + def test_generate_diff_and_apply_circuit_with_install_date(self): + """Test generate diff and apply circuit with date.""" + circuit_uuid = str(uuid4()) + payload = { + "timestamp": 1, + "object_type": "circuits.circuit", + "entity": { + "circuit": { + "cid": f"Circuit {circuit_uuid}", + "install_date": "2026-01-01T00:00:00Z", + "provider": { + "name": f"Provider {uuid4()}", + }, + "type": { + "name": f"Ciruit Type {uuid4()}", + }, + }, + }, + } + + _, response = self.diff_and_apply(payload) + new_circuit = Circuit.objects.get(cid=f"Circuit {circuit_uuid}") + self.assertEqual(new_circuit.install_date, datetime.date(2026, 1, 1)) + + def test_generate_diff_and_apply_site_with_lat_lon(self): + """Test generate diff and apply site with lat and lon.""" + site_uuid = str(uuid4()) + payload = { + "timestamp": 1, + "object_type": "dcim.site", + "entity": { + "site": { + "name": f"Site {site_uuid}", + "latitude": 23.456, + "longitude": 78.910, + }, + }, + } + + _, response = self.diff_and_apply(payload) + new_site = Site.objects.get(name=f"Site {site_uuid}") + self.assertEqual(new_site.latitude, decimal.Decimal("23.456")) + self.assertEqual(new_site.longitude, decimal.Decimal("78.910")) + + payload = { + "timestamp": 1, + "object_type": "dcim.site", + "entity": { + "site": { + "name": f"Site {site_uuid}", + "latitude": 23.456, + "longitude": 78.910, + }, + }, + } + response1 = self.client.post( + self.diff_url, data=payload, format="json", **self.authorization_header + ) + self.assertEqual(response1.status_code, status.HTTP_200_OK) + diff = response1.json().get("change_set", {}) + self.assertEqual(diff.get("changes", []), []) + + def test_generate_diff_and_apply_wrong_type_date(self): + """Test generate diff and apply wrong type date.""" + payload = { + "timestamp": 1, + "object_type": "dcim.site", + "entity": { + "site": { + "name": "Site Generate Diff 1", + "slug": "site-generate-diff-1", + "custom_fields": { + "mydate": { + "date": 12, + }, + }, + }, + } + } + response1 = self.client.post( + self.diff_url, data=payload, format="json", **self.authorization_header + ) + self.assertEqual(response1.status_code, status.HTTP_200_OK) + + diff = response1.json().get("change_set", {}) + + response2 = self.client.post( + self.apply_url, data=diff, format="json", **self.authorization_header + ) + self.assertEqual(response2.status_code, status.HTTP_400_BAD_REQUEST) + + def test_generate_diff_and_apply_vlan_group_with_vid_ranges(self): + """Test generate diff and apply vlan group vid ranges.""" + payload = { + "timestamp": 1, + "object_type": "ipam.vlangroup", + "entity": { + "vlan_group": { + "name": "VLAN Group 1", + "vid_ranges": [1,5,10,15], + }, + }, + } + _, response = self.diff_and_apply(payload) + new_vlan_group = VLANGroup.objects.get(name="VLAN Group 1") + self.assertEqual(new_vlan_group.vid_ranges[0].lower, 1) + self.assertEqual(new_vlan_group.vid_ranges[0].upper, 6) + self.assertEqual(new_vlan_group.vid_ranges[1].lower, 10) + self.assertEqual(new_vlan_group.vid_ranges[1].upper, 16) + + payload = { + "timestamp": 1, + "object_type": "ipam.vlangroup", + "entity": { + "vlan_group": { + "name": "VLAN Group 1", + "vid_ranges": [3,9,12,20], + }, + }, + } + _, response = self.diff_and_apply(payload) + new_vlan_group = VLANGroup.objects.get(name="VLAN Group 1") + self.assertEqual(new_vlan_group.vid_ranges[0].lower, 3) + self.assertEqual(new_vlan_group.vid_ranges[0].upper, 10) + self.assertEqual(new_vlan_group.vid_ranges[1].lower, 12) + self.assertEqual(new_vlan_group.vid_ranges[1].upper, 21) + + def test_generate_diff_and_apply_ip_address_with_assigned_object_interface(self): + """Test ip.""" + payload = { + "timestamp": 1, + "object_type": "ipam.ipaddress", + "entity": { + "ip_address": { + "address": "254.198.174.116", + "status": "deprecated", + "role": "secondary", + "assigned_object_interface": { + "device": { + "name": "Device ABC", + "device_type": { + "manufacturer": { + "name": "Manufacturer ABC" + }, + "model": "Device Type ABC" + }, + "role": { + "name": "Role ABC" + }, + "platform": { + "name": "Platform ABC", + "manufacturer": { + "name": "Manufacturer ABC" + } + }, + "site": { + "name": "Site ABC" + } + }, + "name": "Interface ABC", + "type": "1000base-t", + "mode": "access" + }, + "description": "IP Address description", + "comments": "Lorem ipsum dolor sit amet", + "tags": [ + { + "name": "tag 1" + }, + { + "name": "tag 2" + } + ] + } + } + } + _, response = self.diff_and_apply(payload) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_generate_diff_update_ip_address(self): + """Test generate diff update ip address.""" + payload = { + "timestamp": 1, + "object_type": "ipam.ipaddress", + "entity": { + "ip_address": { + "address": "254.198.174.116", + "status": "deprecated", + "role": "secondary", + } + } + } + _, response = self.diff_and_apply(payload) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + payload = { + "timestamp": 1, + "object_type": "ipam.ipaddress", + "entity": { + "ip_address": { + "address": "254.198.174.116", + "status": "deprecated", + "role": "secondary", + } + } + } + + response1 = self.client.post( + self.diff_url, data=payload, format="json", **self.authorization_header + ) + self.assertEqual(response1.status_code, status.HTTP_200_OK) + diff = response1.json().get("change_set", {}) + self.assertEqual(diff.get("changes", []), []) + + payload = { + "timestamp": 1, + "object_type": "ipam.ipaddress", + "entity": { + "ip_address": { + "address": "254.198.174.116/32", + "status": "deprecated", + "role": "secondary", + } + } + } + + response1 = self.client.post( + self.diff_url, data=payload, format="json", **self.authorization_header + ) + self.assertEqual(response1.status_code, status.HTTP_200_OK) + diff = response1.json().get("change_set", {}) + self.assertEqual(diff.get("changes", []), []) + + payload = { + "timestamp": 1, + "object_type": "ipam.ipaddress", + "entity": { + "ip_address": { + "address": "254.198.174.116", + "status": "active", + "role": "secondary", + } + } + } + + _ = self.diff_and_apply(payload) + ip = IPAddress.objects.get(address="254.198.174.116") + self.assertEqual(ip.status, "active") + + payload = { + "timestamp": 1, + "object_type": "ipam.ipaddress", + "entity": { + "ip_address": { + "address": "254.198.174.116/24", + "status": "deprecated", + } + } + } + _ = self.diff_and_apply(payload) + ip = IPAddress.objects.get(address="254.198.174.116/24") + self.assertEqual(ip.role, "secondary") + self.assertEqual(ip.status, "deprecated") + self.assertEqual(ip.address, netaddr.IPNetwork("254.198.174.0/24")) + + vrf_uuid = str(uuid4()) + payload = { + "timestamp": 1, + "object_type": "ipam.ipaddress", + "entity": { + "ip_address": { + "address": "254.198.174.116/24", + "status": "active", + "vrf": { + "name": f"VRF {vrf_uuid}" + } + } + } + } + _ = self.diff_and_apply(payload) + ip = IPAddress.objects.get(address="254.198.174.116/24", vrf__name=f"VRF {vrf_uuid}") + self.assertEqual(ip.vrf.name, f"VRF {vrf_uuid}") + self.assertEqual(ip.status, "active") + + ip2 = IPAddress.objects.get(address="254.198.174.116/24", vrf__isnull=True) + self.assertEqual(ip2.vrf, None) + self.assertEqual(ip2.status, "deprecated") + + payload = { + "timestamp": 1, + "object_type": "ipam.ipaddress", + "entity": { + "ip_address": { + "address": "254.198.174.116", + "status": "dhcp", + "vrf": { + "name": f"VRF {vrf_uuid}" + } + } + } + } + _ = self.diff_and_apply(payload) + ip = IPAddress.objects.get(address="254.198.174.116", vrf__name=f"VRF {vrf_uuid}") + self.assertEqual(ip.status, "dhcp") + + ip2 = IPAddress.objects.get(address="254.198.174.116/24", vrf__isnull=True) + self.assertEqual(ip2.vrf, None) + self.assertEqual(ip2.status, "deprecated") + + def test_generate_diff_and_apply_complex_vminterface(self): + """Test generate diff and apply and update a complex vm interface.""" + payload = { + "timestamp": 1, + "object_type": "virtualization.vminterface", + "entity": { + "vm_interface": { + "virtual_machine": { + "name": "Virtual Machine 15e00bdf-4294-41df-a450-ffcfec6c7f2b", + "status": "active", + "site": { + "name": "Site 10" + }, + "cluster": { + "name": "Cluster 10", + "type": { + "name": "Cluster type 10" + }, + "group": { + "name": "Cluster group 10" + }, + "status": "active", + "scope_site": { + "name": "Site 10" + } + }, + "role": { + "name": "Role 10" + }, + "platform": { + "name": "Platform 10", + "manufacturer": { + "name": "Manufacturer 10" + } + }, + "vcpus": 1.0, + "memory": "4096", + "disk": "100", + "description": "Virtual Machine A description", + "comments": "Lorem ipsum dolor sit amet", + "tags": [ + { + "name": "tag 1" + } + ] + }, + "name": "Interface 47e8a593-8b74-4e94-9a8e-c02113f0bf88", + "enabled": False, + "mtu": "1500", + "primary_mac_address": { + "mac_address": "00:00:00:00:00:00" + }, + "description": "Interface A description", + "tags": [ + { + "name": "tag 1" + } + ] + } + } + } + _ = self.diff_and_apply(payload) + + payload2 = copy.deepcopy(payload) + payload2['entity']['vm_interface']["mtu"] = "2000" + payload2['entity']['vm_interface']["primary_mac_address"] = { + "mac_address": "00:00:00:00:00:01" + } + _ = self.diff_and_apply(payload2) + vm_interface = VMInterface.objects.get(name="Interface 47e8a593-8b74-4e94-9a8e-c02113f0bf88") + self.assertEqual(vm_interface.mtu, 2000) + self.assertEqual(vm_interface.primary_mac_address.mac_address, "00:00:00:00:00:01") + + def test_generate_diff_and_apply_dedupe_devicetype(self): + """Test generate diff and apply dedupe devicetype in wireless link.""" + payload = { + "timestamp": "2025-04-16T02:58:20.564615Z", + "object_type": "wireless.wirelesslink", + "entity": { + "wireless_link": { + "interface_a": { + "device": { + "name": "Device 1", + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "role": {"name": "Device Role 1"}, + "site": {"name": "Site 1"} + }, + "name": "Radio0/1", + "type": "ieee802.11ac", + "enabled": True + }, + "interface_b": { + "device": { + "name": "Device 2", + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "role": {"name": "Device Role 1"}, + "site": {"name": "Site 1"} + }, + "name": "Radio0/1", + "type": "ieee802.11ac", + "enabled": True + }, + "ssid": "P2P-Link-1", + "status": "connected", + "tenant": {"name": "Tenant 1"}, + "auth_type": "wpa-personal", + "auth_cipher": "aes", + "auth_psk": "P2PLinkKey123!", + "distance": 1.5, + "distance_unit": "km", + "description": "Point-to-point wireless backhaul link", + "comments": "Building A to Building B wireless bridge", + "tags": [ + { + "name": "Tag 1" + }, + { + "name": "Tag 2" + } + ] + } + } + } + + _ = self.diff_and_apply(payload) + + def test_generate_diff_and_apply_provider_with_accounts(self): + """Test generate diff and apply provider with accounts.""" + payload = { + "timestamp": "2025-04-16T02:58:20.564615Z", + "object_type": "circuits.provider", + "entity": { + "provider": { + "name": "Level 3 Communications", + "slug": "level3", + "description": "Global Tier 1 Internet Service Provider", + "comments": "Primary transit provider for data center connectivity", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}], + "accounts": [ + { + "provider": {"name": "Level 3 Communications"}, + "name": "East Coast Account", + "account": "L3-12345", + "description": "East Coast regional services account", + "comments": "Managed through regional NOC" + }, + { + "provider": {"name": "Level 3 Communications"}, + "name": "West Coast Account", + "account": "L3-67890", + "description": "West Coast regional services account", + "comments": "Managed through regional NOC" + } + ], + "asns": [ + { + "asn": "3356", + "rir": {"name": "ARIN"}, + "tenant": {"name": "Tenant 1"}, + "description": "Level 3 Global ASN", + "comments": "Primary transit ASN" + } + ] + } + } + } + + _ = self.diff_and_apply(payload) + provider = Provider.objects.get(name="Level 3 Communications") + self.assertEqual(provider.accounts.count(), 2) + self.assertEqual(provider.asns.count(), 1) + + def test_generate_diff_and_apply_module_bay_with_module(self): + """Test generate diff and apply module bay with module.""" + payload = { + "timestamp": "2025-04-16T02:58:20.564615Z", + "object_type": "dcim.modulebay", + "entity": { + "module_bay": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "Stack Module Bay 2", + "module": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S-STACK" + }, + "module_bay": { + "name": "Module Bay 1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + } + } + + }, + "label": "STACK-2", + "position": "Rear", + "description": "Secondary stacking module bay", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + } + } + _ = self.diff_and_apply(payload) + module_bay = ModuleBay.objects.get(name="Stack Module Bay 2") + self.assertEqual(module_bay.module.device.name, "Device 1") + self.assertEqual(module_bay.module.module_type.manufacturer.name, "Cisco") + self.assertEqual(module_bay.module.module_type.model, "C2960S-STACK") + self.assertEqual(module_bay.module.module_bay.name, "Module Bay 1") + + def test_generate_diff_and_apply_module_bay_circular_ref_fails(self): + """Test generate diff and apply module bay.""" + payload = { + "timestamp": "2025-04-16T02:58:20.564615Z", + "object_type": "dcim.modulebay", + "entity": { + "module_bay": { + "name": "Module Bay 1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module": { + "asset_tag": "1234567890", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S-STACK" + }, + "module_bay": { + "name": "Module Bay 1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module": { + "asset_tag": "1234567890", + } + } + }, + "label": "STACK-2", + "position": "Rear", + "description": "Secondary stacking module bay", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + } + } + response1 = self.client.post( + self.diff_url, data=payload, format="json", **self.authorization_header + ) + self.assertEqual(response1.status_code, status.HTTP_200_OK) + diff = response1.json().get("change_set", {}) + + response2 = self.client.post( + self.apply_url, data=diff, format="json", **self.authorization_header + ) + self.assertEqual(response2.status_code, status.HTTP_400_BAD_REQUEST) + + self.assertIn( + "A module bay cannot belong to a module installed within it.", + _get_error(response2, "dcim.modulebay", "__all__") + ) + + def test_generate_diff_and_apply_virtual_machine_with_primary_ip_4_ok(self): + """Test generate diff and apply virtual machine with primary ip 4 assigned.""" + payload = { + "timestamp": "2025-04-16T02:58:20.564615Z", + "object_type": "virtualization.virtualmachine", + "entity": { + "timestamp": "2025-04-16T13:45:02.045208Z", + "virtual_machine": { + "name": "app-server-01", + "status": "active", + "site": {"name": "Site 1"}, + "cluster": { + "name": "Cluster 1", + "type": {"name": "Cluster Type 1"} + }, + "device": { + "name": "Device 1", + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "role": {"name": "Device Role 1"}, + "site": {"name": "Site 1"}, + "cluster": { + "name": "Cluster 1", + "type": {"name": "Cluster Type 1"} + } + }, + "serial": "VM-2023-001", + "role": {"name": "Application Server"}, + "tenant": {"name": "Tenant 1"}, + "platform": {"name": "Ubuntu 22.04"}, + "primary_ip4": { + "address": "192.168.2.10", + "assigned_object_vm_interface": { + "virtual_machine": { + "name": "app-server-01", + "cluster": { + "name": "Cluster 1", + "type": {"name": "Cluster Type 1"} + }, + "tenant": {"name": "Tenant 1"}, + }, + "name": "eth0", + "enabled": True, + "mtu": "1500", + } + }, + "vcpus": 4.0, + "memory": "214748364", + "disk": "147483647", + "description": "Primary application server instance", + "comments": "Hosts critical business applications", + "tags": [ + { + "name": "Tag 1" + }, + { + "name": "Tag 2" + } + ] + } + } + } + _ = self.diff_and_apply(payload) + + def test_generate_diff_and_apply_update_cluster_location(self): + """Test generate diff and apply update cluster location, same site.""" + payload = { + "timestamp": "2025-04-16T02:58:20.564615Z", + "object_type": "virtualization.cluster", + "entity": { + "cluster": { + "name": "Cluster A", + "type": {"name": "Cluster Type 1"}, + "group": {"name": "Cluster Group 1"}, + "status": "active", + "tenant": {"name": "Tenant 1"}, + "scope_site": {"name": "Site 1"}, + "description": "Cluster 1 Description", + "comments": "Cluster 1 Comments", + "tags": [{"name": "Tag 1"}] + } + }, + } + _ = self.diff_and_apply(payload) + + cluster = Cluster.objects.get(name="Cluster A") + self.assertEqual(cluster.scope.name, "Site 1") + + payload = { + "timestamp": "2025-04-16T02:58:20.564615Z", + "object_type": "virtualization.cluster", + "entity": { + "cluster": { + "name": "Cluster A", + "type": {"name": "Cluster Type 1"}, + "group": {"name": "Cluster Group 1"}, + "status": "active", + "tenant": {"name": "Tenant 1"}, + "scope_location": {"name": "Location 1", "site": {"name": "Site 1"}}, + "description": "Cluster 1 Description", + "comments": "Cluster 1 Comments", + "tags": [{"name": "Tag 1"}] + } + }, + } + _ = self.diff_and_apply(payload) + cluster = Cluster.objects.get(name="Cluster A") + self.assertEqual(cluster.scope.name, "Location 1") + + def diff_and_apply(self, payload): + """Diff and apply the payload.""" + response1 = self.client.post( + self.diff_url, data=payload, format="json", **self.authorization_header + ) + self.assertEqual(response1.status_code, status.HTTP_200_OK) + diff = response1.json().get("change_set", {}) + + response2 = self.client.post( + self.apply_url, data=diff, format="json", **self.authorization_header + ) + self.assertEqual(response2.status_code, status.HTTP_200_OK) + return (response1, response2) diff --git a/netbox_diode_plugin/tests/v4.2.3/tests/test_api_generate_diff.py b/netbox_diode_plugin/tests/v4.2.3/tests/test_api_generate_diff.py new file mode 100644 index 0000000..9698712 --- /dev/null +++ b/netbox_diode_plugin/tests/v4.2.3/tests/test_api_generate_diff.py @@ -0,0 +1,421 @@ +#!/usr/bin/env python +# Copyright 2025 NetBox Labs, Inc. +"""Diode NetBox Plugin - Tests.""" + +import logging +from collections import defaultdict +from types import SimpleNamespace +from unittest import mock +from uuid import uuid4 + +from core.models import ObjectType +from dcim.models import Manufacturer, RackType, Site +from extras.models import CustomField +from extras.models.customfields import CustomFieldTypeChoices +from rest_framework import status +from utilities.testing import APITestCase + +from netbox_diode_plugin.api.authentication import DiodeOAuth2Authentication +from netbox_diode_plugin.plugin_config import get_diode_user + +logger = logging.getLogger(__name__) + +def _get_error(response, object_name, field): + return response.json().get("errors", {}).get(object_name, {}).get(field, []) + + +class GenerateDiffTestCase(APITestCase): + """GenerateDiff test cases.""" + + def setUp(self): + """Set up the test case.""" + self.url = "/netbox/api/plugins/diode/generate-diff/" + + self.authorization_header = {"HTTP_AUTHORIZATION": "Bearer mocked_oauth_token"} + self.diode_user = SimpleNamespace( + user = get_diode_user(), + token_scopes=["netbox:read", "netbox:write"], + token_data={"scope": "netbox:read netbox:write"} + ) + + self.introspect_patcher = mock.patch.object( + DiodeOAuth2Authentication, + '_introspect_token', + return_value=self.diode_user + ) + self.introspect_patcher.start() + + self.object_type = ObjectType.objects.get_for_model(Site) + + self.uuid_field = CustomField.objects.create( + name='myuuid', + type=CustomFieldTypeChoices.TYPE_TEXT, + required=False, + unique=True, + ) + self.uuid_field.object_types.set([self.object_type]) + self.uuid_field.save() + + self.json_field = CustomField.objects.create( + name='some_json', + type=CustomFieldTypeChoices.TYPE_JSON, + required=False, + unique=False, + ) + self.json_field.object_types.set([self.object_type]) + self.json_field.save() + + self.site_uuid = str(uuid4()) + self.site = Site.objects.create( + name="Site Generate Diff 1", + slug="site-generate-diff-1", + facility="Alpha", + description="First test site", + physical_address="123 Fake St Lincoln NE 68588", + shipping_address="123 Fake St Lincoln NE 68588", + comments="Lorem ipsum etcetera", + ) + self.site.custom_field_data[self.uuid_field.name] = self.site_uuid + self.site.custom_field_data[self.json_field.name] = { + "some_key": "some_value", + } + self.site.save() + + self.manufacturer = Manufacturer.objects.create( + name="Manufacturer 1", + ) + self.manufacturer.save() + self.rack_type = RackType.objects.create( + model="Rack Type 1", + slug="rack-type-1", + manufacturer=self.manufacturer, + ) + self.rack_type.save() + + def tearDown(self): + """Clean up after tests.""" + self.introspect_patcher.stop() + super().tearDown() + + def test_generate_diff_create_site(self): + """Test generate diff create site.""" + payload = { + "timestamp": 1, + "object_type": "dcim.site", + "entity": { + "site": { + "name": "A New Site", + "slug": "a-new-site", + }, + } + } + + response = self.send_request(payload) + self.assertEqual(response.status_code, status.HTTP_200_OK) + cs = response.json().get("change_set", {}) + self.assertIsNotNone(cs.get("id")) + changes = cs.get("changes", []) + self.assertEqual(len(changes), 1) + change = changes[0] + self.assertEqual(change.get("object_type"), "dcim.site") + self.assertEqual(change.get("change_type"), "create") + self.assertEqual(change.get("object_id"), None) + self.assertIsNotNone(change.get("ref_id")) + + data = change.get("data", {}) + self.assertEqual(data.get("name"), "A New Site") + self.assertEqual(data.get("slug"), "a-new-site") + + def test_generate_diff_create_site_with_custom_field(self): + """Test generate diff create site with custom field.""" + payload = { + "timestamp": 1, + "object_type": "dcim.site", + "entity": { + "site": { + "name": "A New Site", + "slug": "a-new-site", + "custom_fields": { + "some_json": { + "json": '{"some_key": 1234567890}', + }, + }, + }, + } + } + + response = self.send_request(payload) + self.assertEqual(response.status_code, status.HTTP_200_OK) + cs = response.json().get("change_set", {}) + self.assertIsNotNone(cs.get("id")) + changes = cs.get("changes", []) + self.assertEqual(len(changes), 1) + change = changes[0] + self.assertEqual(change.get("object_type"), "dcim.site") + self.assertEqual(change.get("change_type"), "create") + self.assertEqual(change.get("object_id"), None) + self.assertIsNotNone(change.get("ref_id")) + + data = change.get("data", {}) + self.assertEqual(data.get("name"), "A New Site") + self.assertEqual(data.get("slug"), "a-new-site") + self.assertEqual(data.get("custom_fields", {}).get("some_json", {}).get("some_key"), 1234567890) + + def test_generate_diff_update_site(self): + """Test generate diff update site.""" + """Test generate diff create site.""" + payload = { + "timestamp": 1, + "object_type": "dcim.site", + "entity": { + "site": { + "name": "Site Generate Diff 1", + "slug": "site-generate-diff-1", + "comments": "An updated comment", + }, + } + } + + response = self.send_request(payload) + self.assertEqual(response.status_code, status.HTTP_200_OK) + cs = response.json().get("change_set", {}) + self.assertIsNotNone(cs.get("id")) + changes = cs.get("changes", []) + self.assertEqual(len(changes), 1) + change = changes[0] + self.assertEqual(change.get("object_type"), "dcim.site") + self.assertEqual(change.get("change_type"), "update") + self.assertEqual(change.get("object_id"), self.site.id) + self.assertEqual(change.get("ref_id"), None) + self.assertEqual(change.get("data").get("name"), "Site Generate Diff 1") + + data = change.get("data", {}) + self.assertEqual(data.get("name"), "Site Generate Diff 1") + self.assertEqual(data.get("slug"), "site-generate-diff-1") + self.assertEqual(data.get("comments"), "An updated comment") + + def test_match_site_by_custom_field(self): + """Test match site by custom field.""" + payload = { + "timestamp": 1, + "object_type": "dcim.site", + "entity": { + "site": { + # here name and slug are not present in the payload + # but we expect to match the existing site by the + # unique custom field myuuid + "comments": "A custom comment", + "custom_fields": { + "myuuid": { + "text": self.site_uuid, + }, + }, + }, + } + } + + response = self.send_request(payload) + self.assertEqual(response.status_code, status.HTTP_200_OK) + cs = response.json().get("change_set", {}) + self.assertIsNotNone(cs.get("id")) + changes = cs.get("changes", []) + self.assertEqual(len(changes), 1) + change = changes[0] + self.assertEqual(change.get("object_type"), "dcim.site") + self.assertEqual(change.get("change_type"), "update") + self.assertEqual(change.get("object_id"), self.site.id) + self.assertEqual(change.get("ref_id"), None) + + data = change.get("data", {}) + self.assertEqual(data.get("comments"), "A custom comment") + self.assertEqual(data.get("custom_fields", {}).get("myuuid"), self.site_uuid) + + before = change.get("before", {}) + self.assertEqual(before.get("name"), "Site Generate Diff 1") + self.assertEqual(before.get("slug"), "site-generate-diff-1") + + def test_generate_diff_update_rack_type_autoslug(self): + """Test generate diff update rack type autoslug.""" + payload = { + "timestamp": 1, + "object_type": "dcim.racktype", + "entity": { + "rack_type": { + "model": "Rack Type 1", + "form_factor": "wall-frame", + }, + } + } + + response = self.send_request(payload) + self.assertEqual(response.status_code, status.HTTP_200_OK) + cs = response.json().get("change_set", {}) + self.assertIsNotNone(cs.get("id")) + changes = cs.get("changes", []) + self.assertEqual(len(changes), 1) + change = changes[0] + self.assertEqual(change.get("object_type"), "dcim.racktype") + self.assertEqual(change.get("change_type"), "update") + self.assertEqual(change.get("object_id"), self.rack_type.id) + self.assertEqual(change.get("ref_id"), None) + + data = change.get("data", {}) + self.assertEqual(data.get("model"), "Rack Type 1") + self.assertEqual(data.get("slug"), None) # slug is not set, use prior slug + self.assertEqual(data.get("form_factor"), "wall-frame") + + before = change.get("before", {}) + self.assertEqual(before.get("model"), "Rack Type 1") + # correct slug is present in before data + self.assertEqual(before.get("slug"), "rack-type-1") + + def test_generate_diff_update_rack_type_camel_case(self): + """Test generate diff update rack type with came cased protoJSON.""" + payload = { + "timestamp": 1, + "object_type": "dcim.racktype", + "entity": { + "rackType": { + "slug": "rack-type-1", + "model": "Rack Type 1", + "formFactor": "wall-frame", + }, + } + } + + response = self.send_request(payload) + self.assertEqual(response.status_code, status.HTTP_200_OK) + cs = response.json().get("change_set", {}) + self.assertIsNotNone(cs.get("id")) + changes = cs.get("changes", []) + self.assertEqual(len(changes), 1) + change = changes[0] + self.assertEqual(change.get("object_type"), "dcim.racktype") + self.assertEqual(change.get("change_type"), "update") + self.assertEqual(change.get("object_id"), self.rack_type.id) + self.assertEqual(change.get("ref_id"), None) + + data = change.get("data", {}) + self.assertEqual(data.get("model"), "Rack Type 1") + self.assertEqual(data.get("form_factor"), "wall-frame") + + before = change.get("before", {}) + self.assertEqual(before.get("model"), "Rack Type 1") + + def test_merge_states_failed(self): + """Test merge states failed.""" + payload = { + "timestamp": 1, + "object_type": "ipam.vrf", + "entity": { + "vrf": { + "name": "Customer-A-VRF", + "rd": "65000:100", + "tenant": {"name": "Tenant 1"}, + "enforce_unique": True, + "description": "Isolated routing domain for Customer A", + "comments": "Used for customer's private network services", + "tags": [ + { + "name": "Tag 1" + }, + { + "name": "Tag 2" + } + ], + "import_targets": [ + { + "name": "65000:100", + "description": "Primary import route target" + }, + { + "name": "65000:101", + "description": "Backup import route target" + } + ], + "export_targets": [ + { + "name": "65000:100", + "description": "Primary export route target" + } + ] + } + } + } + + response = self.send_request(payload, status.HTTP_400_BAD_REQUEST) + logger.error(response.json()) + errs = _get_error(response, "ipam.vrf", "__all__") + self.assertEqual(len(errs), 1) + err = errs[0] + self.assertTrue(err.startswith("Conflicting values for 'description' merging duplicate ipam.routetarget")) + + def test_vlangroup_error(self): + """Test vlangroup error.""" + payload = { + "timestamp": 1, + "object_type": "ipam.vlangroup", + "entity": { + "vlan_group": { + "name": "Data Center Core", + "slug": "dc-core", + "scope_site": { + "name": "Data Center West", + "slug": "dc-west", + "status": "active" + }, + "description": "Core network VLANs for data center infrastructure", + "tags": [ + { + "name": "Tag 1" + }, + { + "name": "Tag 2" + } + ] + } + } + } + _ = self.send_request(payload) + + def test_generate_diff_dedupe_different_object_types(self): + """Test generate diff dedupe different object types with same values.""" + payload = { + "timestamp": 1, + "object_type": "dcim.device", + "entity": { + "device": { + "name": "Cat8000V", + "role": {"name": "undefined"}, + "site": {"name": "undefined"}, + "serial": "9OBXJHNNU5V", + "status": "active", + "platform": {"name": "ios", "manufacturer": {"name": "undefined"}}, + "device_type": {"model": "C8000V", "manufacturer": {"name": "undefined"}} + }, + }, + } + response = self.send_request(payload) + self.assertEqual(response.status_code, status.HTTP_200_OK) + cs = response.json().get("change_set", {}) + self.assertIsNotNone(cs.get("id")) + changes = cs.get("changes", []) + self.assertEqual(len(changes), 6) + by_object_type = defaultdict(int) + for change in changes: + by_object_type[change.get("object_type")] += 1 + + self.assertEqual(by_object_type["dcim.device"], 1) + self.assertEqual(by_object_type["dcim.manufacturer"], 1) + self.assertEqual(by_object_type["dcim.platform"], 1) + self.assertEqual(by_object_type["dcim.devicetype"], 1) + self.assertEqual(by_object_type["dcim.site"], 1) + self.assertEqual(by_object_type["dcim.devicerole"], 1) + + def send_request(self, payload, status_code=status.HTTP_200_OK): + """Post the payload to the url and return the response.""" + response = self.client.post( + self.url, data=payload, format="json", **self.authorization_header + ) + self.assertEqual(response.status_code, status_code) + return response diff --git a/netbox_diode_plugin/tests/v4.2.3/tests/test_authentication.py b/netbox_diode_plugin/tests/v4.2.3/tests/test_authentication.py new file mode 100644 index 0000000..1bd754f --- /dev/null +++ b/netbox_diode_plugin/tests/v4.2.3/tests/test_authentication.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python +# Copyright 2025 NetBox Labs, Inc. +"""Diode NetBox Plugin - Authentication Tests.""" + +from types import SimpleNamespace +from unittest import mock + +from django.core.cache import cache +from django.test import TestCase +from rest_framework.exceptions import AuthenticationFailed +from rest_framework.test import APIRequestFactory + +from netbox_diode_plugin.api.authentication import DiodeOAuth2Authentication +from netbox_diode_plugin.plugin_config import get_diode_user + + +class DiodeOAuth2AuthenticationTestCase(TestCase): + """Test cases for DiodeOAuth2Authentication.""" + + def setUp(self): + """Set up test case.""" + self.auth = DiodeOAuth2Authentication() + self.factory = APIRequestFactory() + self.diode_user = SimpleNamespace( + user = get_diode_user(), + token_scopes=["netbox:read", "netbox:write"], + token_data={"scope": "netbox:read netbox:write"} + ) + self.valid_token = "valid_oauth_token" + self.invalid_token = "invalid_oauth_token" + self.token_without_scope = "token_without_scope" + self.token_with_scope = "token_with_scope" + + # Mock the cache + self.cache_patcher = mock.patch.object(cache, 'get') + self.cache_get_mock = self.cache_patcher.start() + self.cache_set_patcher = mock.patch.object(cache, 'set') + self.cache_set_mock = self.cache_set_patcher.start() + + # Mock requests.post for token introspection + self.requests_patcher = mock.patch('requests.post') + self.requests_mock = self.requests_patcher.start() + self.requests_mock.return_value.raise_for_status = mock.Mock() + + # Mock get_diode_auth_introspect_url + self.introspect_url_patcher = mock.patch( + 'netbox_diode_plugin.plugin_config.get_diode_auth_introspect_url', + return_value='http://test-introspect-url' + ) + self.introspect_url_patcher.start() + + self.required_audience_patcher = mock.patch( + 'netbox_diode_plugin.api.authentication.get_required_token_audience', + return_value=[] + ) + self.required_audience_mock = self.required_audience_patcher.start() + + def tearDown(self): + """Clean up after tests.""" + self.cache_patcher.stop() + self.cache_set_patcher.stop() + self.requests_patcher.stop() + self.introspect_url_patcher.stop() + self.required_audience_patcher.stop() + + def test_authenticate_no_auth_header(self): + """Test authentication with no Authorization header.""" + request = self.factory.get('/') + result = self.auth.authenticate(request) + self.assertIsNone(result) + + def test_authenticate_invalid_auth_header_format(self): + """Test authentication with invalid Authorization header format.""" + request = self.factory.get('/', HTTP_AUTHORIZATION='InvalidFormat') + result = self.auth.authenticate(request) + self.assertIsNone(result) + + def test_authenticate_cached_token(self): + """Test authentication with cached token.""" + self.cache_get_mock.return_value = self.diode_user + request = self.factory.get('/', HTTP_AUTHORIZATION=f'Bearer {self.valid_token}') + + user, _ = self.auth.authenticate(request) + self.assertEqual(user, self.diode_user.user) + self.cache_get_mock.assert_called_once() + + def test_authenticate_invalid_token(self): + """Test authentication with invalid token.""" + self.cache_get_mock.return_value = None + self.requests_mock.return_value.json.return_value = {'active': False} + + request = self.factory.get('/', HTTP_AUTHORIZATION=f'Bearer {self.invalid_token}') + + with self.assertRaises(AuthenticationFailed): + self.auth.authenticate(request) + + def test_authenticate_token_with_required_scope(self): + """Test authentication with token having required scope.""" + self.cache_get_mock.return_value = None + self.requests_mock.return_value.json.return_value = { + 'active': True, + 'scope': 'netbox:read netbox:write', + 'exp': 1000, + 'iat': 500 + } + + request = self.factory.get('/', HTTP_AUTHORIZATION=f'Bearer {self.token_with_scope}') + + user, _ = self.auth.authenticate(request) + self.assertEqual(user, self.diode_user.user) + self.cache_set_mock.assert_called_once() + + def test_authenticate_token_with_required_audience(self): + """Test authentication with token having required audience.""" + self.cache_get_mock.return_value = None + self.requests_mock.return_value.json.return_value = { + 'active': True, + 'scope': 'netbox:read netbox:write', + 'exp': 1000, + 'iat': 500 + } + + request = self.factory.get('/', HTTP_AUTHORIZATION=f'Bearer {self.token_with_scope}') + + self.cache_get_mock.return_value = None + self.required_audience_mock.return_value = ['netbox'] + try: + # should fail if the token does not have the required audience + with self.assertRaises(AuthenticationFailed): + self.auth.authenticate(request) + self.required_audience_mock.assert_called_once() + self.cache_set_mock.assert_not_called() + + # should succeed if the token has the required audience + self.requests_mock.return_value.json.return_value = { + 'active': True, + 'aud': ['netbox', 'api', 'other'], + 'scope': 'netbox:read netbox:write', + 'exp': 1000, + 'iat': 500 + } + + user, _ = self.auth.authenticate(request) + self.assertEqual(user, self.diode_user.user) + self.cache_set_mock.assert_called_once() + finally: + self.required_audience_patcher.return_value = [] + + def test_authenticate_token_introspection_failure(self): + """Test authentication when token introspection fails.""" + self.cache_get_mock.return_value = None + self.requests_mock.side_effect = Exception("Introspection failed") + + request = self.factory.get('/', HTTP_AUTHORIZATION=f'Bearer {self.valid_token}') + + with self.assertRaises(AuthenticationFailed): + self.auth.authenticate(request) + + def test_authenticate_token_with_default_expiry(self): + """Test authentication with token having no expiry information.""" + self.cache_get_mock.return_value = None + self.requests_mock.return_value.json.return_value = { + 'active': True, + 'scope': 'netbox:read netbox:write' + } + + request = self.factory.get('/', HTTP_AUTHORIZATION=f'Bearer {self.token_with_scope}') + + user, _ = self.auth.authenticate(request) + self.assertEqual(user, self.diode_user.user) + + self.cache_set_mock.assert_called_once() + + # Get the actual call arguments + call_args = self.cache_set_mock.call_args + if not call_args: + self.fail("Cache set was not called with any arguments") + + # The cache key should start with 'diode:oauth2:introspect:' + cache_key = call_args.args[0] + self.assertTrue(cache_key.startswith('diode:oauth2:introspect:')) + + # The cached value should be the diode user + self.assertEqual(call_args.args[1].user, self.diode_user.user) + self.assertEqual(call_args.args[1].token_scopes, self.diode_user.token_scopes) + + # The timeout should be 300 (default) + self.assertEqual(call_args.kwargs['timeout'], 300) diff --git a/netbox_diode_plugin/tests/v4.2.3/tests/test_diode_clients.py b/netbox_diode_plugin/tests/v4.2.3/tests/test_diode_clients.py new file mode 100644 index 0000000..993d752 --- /dev/null +++ b/netbox_diode_plugin/tests/v4.2.3/tests/test_diode_clients.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python +# Copyright 2025 NetBox Labs, Inc. +"""Diode NetBox Plugin - Diode Clients API Tests.""" + +from unittest import mock + +from django.test import TestCase + +from netbox_diode_plugin.diode.clients import ClientAPI, ClientAPIError + + +class DiodeClientsTestCase(TestCase): + """Test cases for Diode Clients API.""" + + def test_create_client(self): + """Test creating a client.""" + with mock.patch('requests.post') as mock_post: + client = ClientAPI( + base_url="http://test-diode-url", + client_id="test-client-id", + client_secret="test-client-secret" + ) + client._client_auth_token = "test-client-auth-token" + + mock_post.return_value.status_code = 201 + mock_post.return_value.json.return_value = { + "client_id": "test-client-id", + "client_secret": "test-client-secret", + "client_name": "test-client", + "scope": "test-scope" + } + + created = client.create_client( + name="test-client", + scope="test-scope" + ) + + self.assertEqual(created, { + "client_id": "test-client-id", + "client_secret": "test-client-secret", + "client_name": "test-client", + "scope": "test-scope" + }) + + mock_post.assert_called_once_with( + "http://test-diode-url/clients", + headers={ + "Authorization": "Bearer test-client-auth-token" + }, + json={ + "client_name": "test-client", + "scope": "test-scope" + } + ) + + def test_list_clients(self): + """Test listing clients.""" + with mock.patch('requests.get') as mock_get: + client = ClientAPI( + base_url="http://test-diode-url", + client_id="test-client-id", + client_secret="test-client-secret" + ) + client._client_auth_token = "test-client-auth-token" + + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = { + "data": [ + { + "client_id": "test-client-id", + "client_name": "test-client", + "scope": "test-scope" + } + ], + "next_page_token": "test-next-page-token", + "prev_page_token": "test-prev-page-token" + } + + result = client.list_clients(page_size=100) + + self.assertEqual(result["data"], [ + { + "client_id": "test-client-id", + "client_name": "test-client", + "scope": "test-scope" + } + ]) + + self.assertEqual(result["next_page_token"], "test-next-page-token") + self.assertEqual(result["prev_page_token"], "test-prev-page-token") + + mock_get.assert_called_once_with( + "http://test-diode-url/clients", + headers={ + "Authorization": "Bearer test-client-auth-token" + }, + params={ + "page_size": 100, + } + ) + + def test_get_client(self): + """Test getting a client.""" + with mock.patch('requests.get') as mock_get: + client = ClientAPI( + base_url="http://test-diode-url", + client_id="test-client-id", + client_secret="test-client-secret" + ) + client._client_auth_token = "test-client-auth-token" + + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = { + "client_id": "test-client-id", + "client_name": "test-client", + "scope": "test-scope" + } + + result = client.get_client("test-client-id") + + self.assertEqual(result, { + "client_id": "test-client-id", + "client_name": "test-client", + "scope": "test-scope" + }) + + mock_get.assert_called_once_with( + "http://test-diode-url/clients/test-client-id", + headers={ + "Authorization": "Bearer test-client-auth-token" + } + ) + + def test_get_client_raises_error_on_bad_id(self): + """Test getting a client raises an error on bad ID.""" + client = ClientAPI( + base_url="http://test-diode-url", + client_id="test-client-id", + client_secret="test-client-secret" + ) + with self.assertRaises(ValueError): + client.get_client("../bad/../client/id") + + def test_delete_client(self): + """Test deleting a client.""" + with mock.patch('requests.delete') as mock_delete: + client = ClientAPI( + base_url="http://test-diode-url", + client_id="test-client-id", + client_secret="test-client-secret" + ) + client._client_auth_token = "test-client-auth-token" + + mock_delete.return_value.status_code = 204 + mock_delete.return_value.raise_for_status = mock.Mock() + + client.delete_client("test-client-id") + + mock_delete.assert_called_once_with( + "http://test-diode-url/clients/test-client-id", + headers={ + "Authorization": "Bearer test-client-auth-token" + } + ) + + def test_delete_client_raises_error_on_bad_id(self): + """Test deleting a client raises an error on bad ID.""" + client = ClientAPI( + base_url="http://test-diode-url", + client_id="test-client-id", + client_secret="test-client-secret" + ) + with self.assertRaises(ValueError): + client.delete_client("../bad/../client/id") + + def test_authentication_retries(self): + """Test authentication retries.""" + with mock.patch('requests.post') as mock_post: + client = ClientAPI( + base_url="http://test-diode-url", + client_id="test-client-id", + client_secret="test-client-secret" + ) + client._client_auth_token = "test-client-auth-token" + + mock_post.side_effect = [ + ClientAPIError("Failed to create client", 401), + mock.Mock(status_code=200, json=lambda: {"access_token": "new-access-token"}), + mock.Mock(status_code=201, json=lambda: { + "client_id": "test-client-id", + "client_secret": "test-client-secret", + "client_name": "test-client", + "scope": "diode:read diode:write" + }), + ] + + result = client.create_client("test-client", "diode:read diode:write") + self.assertEqual(result, { + "client_id": "test-client-id", + "client_secret": "test-client-secret", + "client_name": "test-client", + "scope": "diode:read diode:write" + }) + + self.assertEqual(mock_post.call_count, 3) + + mock_post.assert_has_calls([ + mock.call("http://test-diode-url/clients", + headers={ + "Authorization": "Bearer test-client-auth-token" + }, + json={ + "client_name": "test-client", + "scope": "diode:read diode:write", + } + ), + mock.call("http://test-diode-url/token", + data='grant_type=client_credentials&client_id=test-client-id&client_secret=test-client-secret&scope=diode%3Aread+diode%3Awrite', + headers={'Content-Type': 'application/x-www-form-urlencoded'} + ), + mock.call("http://test-diode-url/clients", + headers={ + "Authorization": "Bearer new-access-token" + }, + json={ + "client_name": "test-client", + "scope": "diode:read diode:write", + } + ), + ]) + + diff --git a/netbox_diode_plugin/tests/v4.2.3/tests/test_forms.py b/netbox_diode_plugin/tests/v4.2.3/tests/test_forms.py new file mode 100644 index 0000000..4afd4f1 --- /dev/null +++ b/netbox_diode_plugin/tests/v4.2.3/tests/test_forms.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python +# Copyright 2025 NetBox Labs, Inc. +"""Diode NetBox Plugin - Tests.""" +from unittest import mock + +from django.test import TestCase + +from netbox_diode_plugin.forms import SettingsForm +from netbox_diode_plugin.models import Setting + + +class SettingsFormTestCase(TestCase): + """Test case for the SettingsForm.""" + + def setUp(self): + """Set up the test case.""" + self.setting = Setting.objects.create( + diode_target="grpc://localhost:8080/diode" + ) + + def test_form_initialization_with_override_allowed(self): + """Test form initialization when override is allowed.""" + with mock.patch( + "netbox_diode_plugin.forms.get_plugin_config" + ) as mock_get_plugin_config: + mock_get_plugin_config.return_value = None + form = SettingsForm(instance=self.setting) + mock_get_plugin_config.assert_called_with( + "netbox_diode_plugin", "diode_target_override" + ) + self.assertFalse(form.fields["diode_target"].disabled) + self.assertNotIn( + "This field is not allowed to be modified.", + form.fields["diode_target"].help_text, + ) + + def test_form_initialization_with_diode_target_override(self): + """Test form initialization when override is disallowed.""" + with mock.patch( + "netbox_diode_plugin.forms.get_plugin_config" + ) as mock_get_plugin_config: + mock_get_plugin_config.return_value = "grpc://localhost:8080/diode" + form = SettingsForm(instance=self.setting) + mock_get_plugin_config.assert_called_with( + "netbox_diode_plugin", "diode_target_override" + ) + self.assertTrue(form.fields["diode_target"].disabled) + self.assertEqual( + "This field is not allowed to be modified.", + form.fields["diode_target"].help_text, + ) diff --git a/netbox_diode_plugin/tests/v4.2.3/tests/test_generate_matching_docs.py b/netbox_diode_plugin/tests/v4.2.3/tests/test_generate_matching_docs.py new file mode 100644 index 0000000..30597b7 --- /dev/null +++ b/netbox_diode_plugin/tests/v4.2.3/tests/test_generate_matching_docs.py @@ -0,0 +1,629 @@ +#!/usr/bin/env python +# Copyright 2025 NetBox Labs, Inc. +"""Diode NetBox Plugin - Tests for generate_matching_docs command.""" + +import io +import sys +from unittest import mock +from unittest.mock import patch + +from django.core.management import call_command +from django.core.management.base import CommandError +from django.db.models import Q +from django.test import TestCase + +from netbox_diode_plugin.api.matcher import AutoSlugMatcher, ObjectMatchCriteria +from netbox_diode_plugin.management.commands.generate_matching_docs import ( + Command, + MatcherInfo, +) + + +class MatcherInfoTestCase(TestCase): + """Test case for MatcherInfo dataclass.""" + + def test_matcher_info_creation(self): + """Test creating MatcherInfo with all fields.""" + info = MatcherInfo( + name="test_matcher", + fields=["field1", "field2"], + condition="field1 is NOT NULL", + description="Test matcher description", + matcher_type="ObjectMatchCriteria", + version_constraints="≥4.3.0" + ) + + self.assertEqual(info.name, "test_matcher") + self.assertEqual(info.fields, ["field1", "field2"]) + self.assertEqual(info.condition, "field1 is NOT NULL") + self.assertEqual(info.description, "Test matcher description") + self.assertEqual(info.matcher_type, "ObjectMatchCriteria") + self.assertEqual(info.version_constraints, "≥4.3.0") + + def test_matcher_info_defaults(self): + """Test creating MatcherInfo with default values.""" + info = MatcherInfo(name="test_matcher") + + self.assertEqual(info.name, "test_matcher") + self.assertIsNone(info.fields) + self.assertIsNone(info.condition) + self.assertIsNone(info.description) + self.assertEqual(info.matcher_type, "ObjectMatchCriteria") + self.assertIsNone(info.version_constraints) + + +class GenerateMatchingDocsCommandTestCase(TestCase): + """Test case for the generate_matching_docs command.""" + + def setUp(self): + """Set up the test case.""" + self.command = Command() + self.command.stdout = io.StringIO() + self.command.stderr = io.StringIO() + + def test_add_arguments(self): + """Test that the command accepts the --output argument.""" + from django.core.management import get_commands + from django.core.management.base import BaseCommand + + # Verify the command is registered + commands = get_commands() + self.assertIn('generate_matching_docs', commands) + + def test_extract_condition_description_none(self): + """Test extracting condition description from None.""" + result = self.command.extract_condition_description(None) + self.assertEqual(result, "None") + + def test_extract_condition_description_simple(self): + """Test extracting condition description from a simple condition.""" + condition = Q(field1__isnull=True) + result = self.command.extract_condition_description(condition) + self.assertEqual(result, "field1 is NULL") + + def test_extract_condition_description_complex(self): + """Test extracting condition description from a complex condition.""" + condition = Q(field1__isnull=True) & Q(field2="value") + result = self.command.extract_condition_description(condition) + self.assertEqual(result, "field1 is NULL AND field2 = value") + + def test_extract_condition_description_or(self): + """Test extracting condition description with OR connector.""" + condition = Q(field1="value1") | Q(field2="value2") + result = self.command.extract_condition_description(condition) + self.assertEqual(result, "field1 = value1 OR field2 = value2") + + def test_extract_condition_description_not_null(self): + """Test extracting condition description for NOT NULL.""" + condition = Q(field1__isnull=False) + result = self.command.extract_condition_description(condition) + self.assertEqual(result, "field1 is NOT NULL") + + def test_get_matcher_description_ip_address_global(self): + """Test getting matcher description for IP address global matcher.""" + mock_matcher = mock.MagicMock() + mock_matcher.name = "logical_ip_address_global_no_vrf" + mock_matcher.ip_fields = ["address"] + mock_matcher.vrf_field = "vrf" + + result = self.command.get_matcher_description(mock_matcher) + self.assertEqual(result, "Matches IP address address in global namespace (no VRF)") + + def test_get_matcher_description_ip_address_vrf(self): + """Test getting matcher description for IP address VRF matcher.""" + mock_matcher = mock.MagicMock() + mock_matcher.name = "logical_ip_address_within_vrf" + mock_matcher.ip_fields = ["address"] + mock_matcher.vrf_field = "vrf" + + result = self.command.get_matcher_description(mock_matcher) + self.assertEqual(result, "Matches IP address address within VRF") + + def test_get_matcher_description_ip_range(self): + """Test getting matcher description for IP range matcher.""" + mock_matcher = mock.MagicMock() + mock_matcher.name = "logical_ip_range_start_end_within_vrf" + mock_matcher.ip_fields = ["start_address", "end_address"] + mock_matcher.vrf_field = "vrf" + + result = self.command.get_matcher_description(mock_matcher) + self.assertEqual(result, "Matches IP range start_address, end_address within VRF context") + + def test_get_matcher_description_standard_fields(self): + """Test getting matcher description for standard field-based matcher.""" + matcher = ObjectMatchCriteria( + fields=["name", "site"], + name="test_matcher", + model_class=None, + condition=None + ) + + result = self.command.get_matcher_description(matcher) + self.assertEqual(result, "Matches on unique constraint fields: name, site") + + def test_get_matcher_description_with_condition(self): + """Test getting matcher description for matcher with condition.""" + matcher = ObjectMatchCriteria( + fields=["name", "site"], + name="test_matcher", + model_class=None, + condition=Q(site__isnull=False) + ) + + result = self.command.get_matcher_description(matcher) + self.assertEqual(result, "Matches on unique constraint fields: name, site where site is NOT NULL") + + def test_get_matcher_description_custom(self): + """Test getting matcher description for custom matcher.""" + matcher = ObjectMatchCriteria( + fields=None, + name="test_matcher", + model_class=None, + condition=None + ) + + result = self.command.get_matcher_description(matcher) + self.assertEqual(result, "Custom matcher") + + def test_get_version_constraints_none(self): + """Test getting version constraints when none are set.""" + mock_matcher = mock.MagicMock() + mock_matcher.min_version = None + mock_matcher.max_version = None + + result = self.command.get_version_constraints(mock_matcher) + self.assertIsNone(result) + + def test_get_version_constraints_min_only(self): + """Test getting version constraints with only min_version.""" + mock_matcher = mock.MagicMock() + mock_matcher.min_version = "4.3.0" + mock_matcher.max_version = None + + result = self.command.get_version_constraints(mock_matcher) + self.assertEqual(result, "≥4.3.0") + + def test_get_version_constraints_max_only(self): + """Test getting version constraints with only max_version.""" + mock_matcher = mock.MagicMock() + mock_matcher.min_version = None + mock_matcher.max_version = "4.2.99" + + result = self.command.get_version_constraints(mock_matcher) + self.assertEqual(result, "≤4.2.99") + + def test_get_version_constraints_both(self): + """Test getting version constraints with both min and max.""" + mock_matcher = mock.MagicMock() + mock_matcher.min_version = "4.3.0" + mock_matcher.max_version = "4.3.99" + + result = self.command.get_version_constraints(mock_matcher) + self.assertEqual(result, "≥4.3.0 ≤4.3.99") + + @mock.patch('netbox_diode_plugin.management.commands.generate_matching_docs._LOGICAL_MATCHERS') + def test_analyze_logical_matchers(self, mock_logical_matchers): + """Test analyzing logical matchers.""" + # Create mock matchers + mock_matcher1 = mock.MagicMock() + mock_matcher1.name = "test_matcher_1" + mock_matcher1.fields = ["name"] + mock_matcher1.condition = None + mock_matcher1.min_version = "4.3.0" + mock_matcher1.max_version = None + + mock_matcher2 = mock.MagicMock() + mock_matcher2.name = "test_matcher_2" + mock_matcher2.fields = ["name", "site"] + mock_matcher2.condition = Q(site__isnull=False) + mock_matcher2.min_version = None + mock_matcher2.max_version = "4.2.99" + + # Mock the matcher factory + mock_logical_matchers.items.return_value = [ + ("dcim.site", lambda: [mock_matcher1, mock_matcher2]) + ] + + result = self.command.analyze_logical_matchers() + + self.assertIn("dcim.site", result) + self.assertEqual(len(result["dcim.site"]), 2) + + # Check first matcher + matcher1_info = result["dcim.site"][0] + self.assertEqual(matcher1_info.name, "test_matcher_1") + self.assertEqual(matcher1_info.fields, ["name"]) + self.assertEqual(matcher1_info.condition, "None") + self.assertEqual(matcher1_info.version_constraints, "≥4.3.0") + self.assertEqual(matcher1_info.matcher_source, "logical") + + # Check second matcher + matcher2_info = result["dcim.site"][1] + self.assertEqual(matcher2_info.name, "test_matcher_2") + self.assertEqual(matcher2_info.fields, ["name", "site"]) + self.assertEqual(matcher2_info.condition, "site is NOT NULL") + self.assertEqual(matcher2_info.version_constraints, "≤4.2.99") + self.assertEqual(matcher2_info.matcher_source, "logical") + + @mock.patch('netbox_diode_plugin.management.commands.generate_matching_docs.extract_supported_models') + @mock.patch('netbox_diode_plugin.management.commands.generate_matching_docs.get_model_matchers') + def test_analyze_builtin_matchers(self, mock_get_model_matchers, mock_supported_models): + """Test analyzing builtin matchers.""" + # Create mock model class + mock_model_class = mock.MagicMock() + mock_model_class.__name__ = "TestModel" + + # Create mock builtin matchers + mock_unique_matcher = mock.MagicMock() + mock_unique_matcher.name = "unique_name" + mock_unique_matcher.fields = ["name"] + mock_unique_matcher.condition = None + mock_unique_matcher.min_version = None + mock_unique_matcher.max_version = None + + mock_constraint_matcher = mock.MagicMock() + mock_constraint_matcher.name = "test_constraint" + mock_constraint_matcher.fields = ["field1", "field2"] + mock_constraint_matcher.condition = Q(field1__isnull=True) + mock_constraint_matcher.min_version = None + mock_constraint_matcher.max_version = None + + # Mock logical matcher (should be skipped) + mock_logical_matcher = mock.MagicMock() + mock_logical_matcher.name = "logical_test" + mock_logical_matcher.fields = ["name"] + mock_logical_matcher.condition = None + mock_logical_matcher.min_version = None + mock_logical_matcher.max_version = None + + # Mock the supported models and get_model_matchers + mock_supported_models.return_value = { + "dcim.site": {"model": mock_model_class} + } + mock_get_model_matchers.return_value = [ + mock_unique_matcher, + mock_constraint_matcher, + mock_logical_matcher # This should be skipped + ] + + result = self.command.analyze_builtin_matchers() + + self.assertIn("dcim.site", result) + self.assertEqual(len(result["dcim.site"]), 2) # Should only include builtin matchers + + # Check unique field matcher + unique_matcher_info = result["dcim.site"][0] + self.assertEqual(unique_matcher_info.name, "unique_name") + self.assertEqual(unique_matcher_info.fields, ["name"]) + self.assertEqual(unique_matcher_info.matcher_source, "builtin") + + # Check constraint matcher + constraint_matcher_info = result["dcim.site"][1] + self.assertEqual(constraint_matcher_info.name, "test_constraint") + self.assertEqual(constraint_matcher_info.fields, ["field1", "field2"]) + self.assertEqual(constraint_matcher_info.matcher_source, "builtin") + + def test_combine_matchers(self): + """Test combining logical and builtin matchers.""" + # Create logical matchers + logical_matcher = MatcherInfo( + name="logical_test", + fields=["name"], + condition="N/A", + description="Logical matcher", + version_constraints="All versions", + matcher_source="logical" + ) + + # Create builtin matchers + builtin_matcher = MatcherInfo( + name="builtin_test", + fields=["name"], + condition="N/A", + description="Builtin matcher", + version_constraints="All versions", + matcher_source="builtin" + ) + + logical_docs = { + "dcim.site": [logical_matcher], + "dcim.device": [logical_matcher] + } + + builtin_docs = { + "dcim.site": [builtin_matcher], + "ipam.prefix": [builtin_matcher] + } + + result = self.command.combine_matchers(logical_docs, builtin_docs) + + # Check that all object types are included + self.assertIn("dcim.site", result) + self.assertIn("dcim.device", result) + self.assertIn("ipam.prefix", result) + + # Check that dcim.site has both logical and builtin matchers + site_matchers = result["dcim.site"] + self.assertEqual(len(site_matchers), 2) + + # Check that logical matcher comes first (as it was added first) + self.assertEqual(site_matchers[0].name, "logical_test") + self.assertEqual(site_matchers[0].matcher_source, "logical") + self.assertEqual(site_matchers[1].name, "builtin_test") + self.assertEqual(site_matchers[1].matcher_source, "builtin") + + # Check that other object types have correct matchers + self.assertEqual(len(result["dcim.device"]), 1) + self.assertEqual(result["dcim.device"][0].matcher_source, "logical") + + self.assertEqual(len(result["ipam.prefix"]), 1) + self.assertEqual(result["ipam.prefix"][0].matcher_source, "builtin") + + def test_get_matcher_description_builtin_types(self): + """Test getting matcher description for different builtin matcher types.""" + # Test CustomFieldMatcher + mock_custom_field_matcher = mock.MagicMock() + mock_custom_field_matcher.custom_field = "test_field" + mock_custom_field_matcher.fields = None + mock_custom_field_matcher.ip_fields = None + mock_custom_field_matcher.vrf_field = None + + result = self.command.get_matcher_description(mock_custom_field_matcher) + self.assertEqual(result, "Matches on unique custom field: test_field") + + def test_get_matcher_description_autoslug(self): + """Test getting matcher description for AutoSlugMatcher.""" + # Test AutoSlugMatcher + autoslug_matcher = AutoSlugMatcher( + name="test_autoslug", + model_class=None, + slug_field="slug" + ) + + result = self.command.get_matcher_description(autoslug_matcher) + self.assertEqual(result, "Matches on auto-generated slug field: slug") + + def test_get_matcher_description_unique_field(self): + """Test getting matcher description for unique field matcher.""" + # Test unique field matcher + mock_unique_matcher = mock.MagicMock() + mock_unique_matcher.name = "unique_name" + mock_unique_matcher.fields = ["name"] + mock_unique_matcher.ip_fields = None + mock_unique_matcher.vrf_field = None + mock_unique_matcher.custom_field = None + mock_unique_matcher.slug_field = None + + result = self.command.get_matcher_description(mock_unique_matcher) + self.assertEqual(result, "Matches on unique field(s): name") + + def test_get_matcher_description_unique_constraint(self): + """Test getting matcher description for unique constraint matcher.""" + # Test unique constraint matcher + mock_constraint_matcher = mock.MagicMock() + mock_constraint_matcher.name = "test_constraint" + mock_constraint_matcher.fields = ["field1", "field2"] + mock_constraint_matcher.condition = None + mock_constraint_matcher.custom_field = None + mock_constraint_matcher.slug_field = None + + result = self.command.get_matcher_description(mock_constraint_matcher) + self.assertEqual(result, "Matches on unique constraint fields: field1, field2") + + def test_generate_markdown_table_empty(self): + """Test generating markdown table with empty documentation.""" + docs = {} + result = self.command.generate_markdown_table(docs) + + expected_lines = [ + "# NetBox Diode Plugin - Object Matching Criteria", + "", + "This document describes how the Diode NetBox Plugin matches existing objects when applying changes.", + "" + ] + + for line in expected_lines: + self.assertIn(line, result) + + def test_generate_markdown_table_with_matchers(self): + """Test generating markdown table with matchers.""" + matcher_info1 = MatcherInfo( + name="test_matcher_1", + fields=["name"], + condition="N/A", + description="Test description 1", + version_constraints="All versions", + matcher_source="logical" + ) + + matcher_info2 = MatcherInfo( + name="test_matcher_2", + fields=["name", "site"], + condition="site is NOT NULL", + description="Test description 2", + version_constraints="≥4.3.0", + matcher_source="logical" + ) + + docs = { + "dcim.site": [matcher_info1, matcher_info2] + } + + result = self.command.generate_markdown_table(docs) + + # Check header + self.assertIn("# NetBox Diode Plugin - Object Matching Criteria", result) + self.assertIn("## dcim.site", result) + + # Check table header + self.assertIn("| Matcher Name | Order of Precedence | Type | Fields | Condition | Description | Version Constraints |", result) + self.assertIn("|--------------|---------------------|------|--------|-----------|-------------|---------------------|", result) + + # Check table rows + self.assertIn("| test_matcher_1 | 1 | logical | name | N/A | Test description 1 | All versions |", result) + self.assertIn("| test_matcher_2 | 2 | logical | name, site | site is NOT NULL | Test description 2 | ≥4.3.0 |", result) + + def test_generate_markdown_table_with_pipe_escaping(self): + """Test generating markdown table with pipe character escaping.""" + matcher_info = MatcherInfo( + name="test|matcher", + fields=["field|1", "field|2"], + condition="field|1 is NOT NULL", + description="Test|description", + version_constraints="≥4.3.0|test", + matcher_source="logical" + ) + + docs = { + "dcim.site": [matcher_info] + } + + result = self.command.generate_markdown_table(docs) + + # Check that pipe characters are escaped + self.assertIn( + "| test\\|matcher | 1 | logical | field\\|1, field\\|2 | field\\|1 is NOT NULL | Test\\|description | ≥4.3.0\\|test |", + result, + ) + + def test_generate_markdown_table_no_matchers(self): + """Test generating markdown table for object type with no matchers.""" + docs = { + "dcim.site": [] + } + + result = self.command.generate_markdown_table(docs) + + self.assertIn("## dcim.site", result) + self.assertIn("No specific matching criteria defined.", result) + + def test_generate_markdown_table_sorted_object_types(self): + """Test that object types are sorted in the output.""" + matcher_info = MatcherInfo( + name="test_matcher", + fields=["name"], + condition="N/A", + description="Test description", + version_constraints="All versions" + ) + + docs = { + "dcim.device": [matcher_info], + "dcim.site": [matcher_info], + "ipam.prefix": [matcher_info] + } + + result = self.command.generate_markdown_table(docs) + + # Check that sections appear in alphabetical order + site_index = result.find("## dcim.site") + device_index = result.find("## dcim.device") + prefix_index = result.find("## ipam.prefix") + + self.assertLess(device_index, site_index) + self.assertLess(site_index, prefix_index) + + def test_condition_extraction_with_none_Q_condition(self): + """Test edge cases in condition extraction.""" + # Test with non-Q condition + condition = "simple_string_condition" + result = self.command.extract_condition_description(condition) + self.assertEqual(result, "simple_string_condition") + + def test_condition_extraction_with_Q_condition_with_no_children(self): + """Test with Q condition that has no children.""" + condition = Q() + result = self.command.extract_condition_description(condition) + self.assertEqual(result, "") + + def test_condition_extraction_with_Q_condition_with_children(self): + """Test with complex nested condition.""" + condition = Q(field1__isnull=True) & (Q(field2="value1") | Q(field2="value2")) + result = self.command.extract_condition_description(condition) + self.assertEqual(result, "field1 is NULL AND (OR: ('field2', 'value1'), ('field2', 'value2'))") + + def test_matcher_description_edge_cases(self): + """Test edge cases in matcher description generation.""" + # Test with matcher that has fields but no condition + matcher = ObjectMatchCriteria( + fields=["name"], + name="test_matcher", + model_class=None, + condition=None + ) + + result = self.command.get_matcher_description(matcher) + self.assertEqual(result, "Matches on unique constraint fields: name") + + # Test with matcher that has condition but no fields + matcher = ObjectMatchCriteria( + fields=None, + name="test_matcher", + model_class=None, + condition=Q(field1__isnull=True) + ) + + with mock.patch.object(self.command, 'extract_condition_description') as mock_extract: + mock_extract.return_value = "field1 is NULL" + result = self.command.get_matcher_description(matcher) + + self.assertEqual(result, "Custom matcher") + + def test_version_constraints_edge_cases(self): + """Test edge cases in version constraints.""" + # Test with empty string versions + mock_matcher = mock.MagicMock() + mock_matcher.min_version = "" + mock_matcher.max_version = "" + + result = self.command.get_version_constraints(mock_matcher) + self.assertEqual(result, None) + + # Test with whitespace versions + mock_matcher = mock.MagicMock() + mock_matcher.min_version = " 4.3.0 " + mock_matcher.max_version = " 4.3.99 " + + result = self.command.get_version_constraints(mock_matcher) + self.assertEqual(result, "≥ 4.3.0 ≤ 4.3.99 ") + + def test_markdown_table_with_empty_values(self): + """Test edge cases in markdown table generation.""" + # Test with None values + matcher_info = MatcherInfo( + name="test_matcher", + fields=None, + condition=None, + description=None, + version_constraints=None, + matcher_source="logical" + ) + + docs = { + "dcim.site": [matcher_info] + } + + result = self.command.generate_markdown_table(docs) + + # Check that None values are handled gracefully + self.assertIn("| test_matcher | 1 | logical | | N/A | N/A | All versions |", result) + + def test_markdown_table_with_empty_fields(self): + """Test with empty fields list.""" + matcher_info = MatcherInfo( + name="test_matcher", + fields=[], + condition="N/A", + description="Test description", + version_constraints="All versions", + matcher_source="logical" + ) + + docs = { + "dcim.site": [matcher_info] + } + + result = self.command.generate_markdown_table(docs) + + # Check that empty fields list is handled + self.assertIn("| test_matcher | 1 | logical | | N/A | Test description | All versions |", result) diff --git a/netbox_diode_plugin/tests/v4.2.3/tests/test_models.py b/netbox_diode_plugin/tests/v4.2.3/tests/test_models.py new file mode 100644 index 0000000..fd9571d --- /dev/null +++ b/netbox_diode_plugin/tests/v4.2.3/tests/test_models.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python +# Copyright 2025 NetBox Labs, Inc. +"""Diode NetBox Plugin - Tests.""" +from django.core.exceptions import ValidationError +from django.test import TestCase + +from netbox_diode_plugin.models import Setting + + +class SettingModelTestCase(TestCase): + """Test case for the models.""" + + def test_validators(self): + """Check Setting model field validators are functional.""" + setting = Setting(diode_target="http://localhost:8080") + + with self.assertRaises(ValidationError): + setting.clean_fields() + + + def test_str(self): + """Check Setting model string representation.""" + setting = Setting(diode_target="http://localhost:8080") + self.assertEqual(str(setting), "") + + + def test_absolute_url(self): + """Check Setting model absolute URL.""" + setting = Setting() + self.assertEqual(setting.get_absolute_url(), "/netbox/plugins/diode/settings/") diff --git a/netbox_diode_plugin/tests/v4.2.3/tests/test_plugin_config.py b/netbox_diode_plugin/tests/v4.2.3/tests/test_plugin_config.py new file mode 100644 index 0000000..ade00c7 --- /dev/null +++ b/netbox_diode_plugin/tests/v4.2.3/tests/test_plugin_config.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python +# Copyright 2025 NetBox Labs, Inc. +"""Diode NetBox Plugin - Tests.""" + +from django.contrib.auth import get_user_model +from django.test import TestCase + +from netbox_diode_plugin.plugin_config import get_diode_auth_introspect_url, get_diode_user + +User = get_user_model() + + +class PluginConfigTestCase(TestCase): + """Test case for plugin config helpers.""" + + def test_get_diode_auth_introspect_url(self): + """Test get_diode_auth_introspect_url function.""" + expected = "http://localhost:8080/diode/auth/introspect" + self.assertEqual(get_diode_auth_introspect_url(), expected) + + def test_get_diode_user(self): + """Test get_diode_user function.""" + diode_user = get_diode_user() + expected_diode_user = User.objects.get(username="diode") + self.assertEqual(diode_user, expected_diode_user) + diff --git a/netbox_diode_plugin/tests/v4.2.3/tests/test_updates.py b/netbox_diode_plugin/tests/v4.2.3/tests/test_updates.py new file mode 100644 index 0000000..57e4981 --- /dev/null +++ b/netbox_diode_plugin/tests/v4.2.3/tests/test_updates.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python +# Copyright 2024 NetBox Labs Inc +"""Diode NetBox Plugin - Tests.""" + +import inspect +import json +import logging +import os +from types import SimpleNamespace +from unittest import mock + +from django.db.models import QuerySet +from rest_framework import status +from utilities.testing import APITestCase + +from netbox_diode_plugin.api.authentication import DiodeOAuth2Authentication +from netbox_diode_plugin.api.common import harmonize_formats +from netbox_diode_plugin.api.plugin_utils import get_object_type_model +from netbox_diode_plugin.plugin_config import get_diode_user + +logger = logging.getLogger(__name__) + + +def _harmonize_formats(data): + data = harmonize_formats(data) + return _tuples_to_lists(data) + +def _tuples_to_lists(data): + if isinstance(data, tuple | list): + return [_tuples_to_lists(d) for d in data] + if isinstance(data, dict): + return {k: _tuples_to_lists(v) for k, v in data.items()} + return data + +def load_test_cases(cls): + """Class decorator to load test cases and create test methods.""" + logger.debug("Loading apply updates test cases") + current_dir = os.path.dirname(os.path.abspath(__file__)) + test_data_path = os.path.join(current_dir, "test_updates_cases.json") + + if not os.path.exists(test_data_path): + raise FileNotFoundError(f"Test data file not found at {test_data_path}") + + def _create_and_update_test_case(case): + object_type = case["object_type"] + + def test_func(self): + model = get_object_type_model(object_type) + + payload = { + "timestamp": 1, + "object_type": object_type, + "entity": case["create"], + } + res = self.send_request(self.diff_url, payload) + self.assertEqual(res.status_code, status.HTTP_200_OK) + diff = res.json().get("change_set", {}) + res = self.client.post( + self.apply_url, data=diff, format="json", **self.authorization_header + ) + self.assertEqual(res.status_code, status.HTTP_200_OK) + # lookup the object and check fields + obj = model.objects.get(**case["lookup"]) + self._check_expect(obj, case["create_expect"]) + + # resending the same payload should not change anything + payload = { + "timestamp": 2, + "object_type": object_type, + "entity": case["create"], + } + res = self.send_request(self.diff_url, payload) + self.assertEqual(res.status_code, status.HTTP_200_OK) + + change_set = res.json().get("change_set", {}) + if change_set.get("changes", []) != []: + logger.error(f"Unexpected change set {json.dumps(change_set, indent=4)}") + + self.assertEqual(res.json().get("change_set", {}).get("changes", []), []) + + # updating the object + payload = { + "timestamp": 3, + "object_type": object_type, + "entity": case["update"], + } + res = self.send_request(self.diff_url, payload) + self.assertEqual(res.status_code, status.HTTP_200_OK) + + diff = res.json().get("change_set", {}) + res = self.client.post( + self.apply_url, data=diff, format="json", **self.authorization_header + ) + self.assertEqual(res.status_code, status.HTTP_200_OK) + obj = model.objects.get(**case["lookup"]) + self._check_expect(obj, case["update_expect"]) + + test_func.__name__ = f"test_updates_{case['name']}" + return test_func + + with open(test_data_path) as f: + test_cases = json.load(f) + for case in test_cases: + t = _create_and_update_test_case(case) + logger.debug(f"Creating test case {t.__name__}") + setattr(cls, t.__name__, t) + + return cls + +@load_test_cases +class ApplyUpdatesTestCase(APITestCase): + """diff/create/update test cases.""" + + @classmethod + def setUpClass(cls): + """Set up the test cases.""" + super().setUpClass() + + def setUp(self): + """Set up the test case.""" + self.diff_url = "/netbox/api/plugins/diode/generate-diff/" + self.apply_url = "/netbox/api/plugins/diode/apply-change-set/" + self.authorization_header = {"HTTP_AUTHORIZATION": "Bearer mocked_oauth_token"} + self.diode_user = SimpleNamespace( + user = get_diode_user(), + token_scopes=["netbox:read", "netbox:write"], + token_data={"scope": "netbox:read netbox:write"} + ) + + self.introspect_patcher = mock.patch.object( + DiodeOAuth2Authentication, + '_introspect_token', + return_value=self.diode_user + ) + self.introspect_patcher.start() + + def tearDown(self): + """Clean up after tests.""" + self.introspect_patcher.stop() + super().tearDown() + + def _follow_path(self, obj, path): + cur = obj + for i, p in enumerate(path): + if p.isdigit(): + p = int(p) + cur = cur[p] + else: + cur = getattr(cur, p) + if i != len(path) - 1: + self.assertIsNotNone(cur) + if callable(cur): + try: + signature = inspect.signature(cur) + if len(signature.parameters) == 0: + cur = cur() + except ValueError: + pass + if isinstance(cur, QuerySet): + cur = list(cur) + return cur + + def _check_set_by(self, obj, path, value): + key = path[-1][len("__by_"):] + path = path[:-1] + cur = self._follow_path(obj, path) + + if isinstance(value, list | tuple): + vals = set(value) + else: + vals = {value} + + cvals = {_harmonize_formats(getattr(c, key)) for c in cur} + self.assertEqual(cvals, vals) + + def _check_equals(self, obj, path, value): + cur = self._follow_path(obj, path) + cur = _harmonize_formats(cur) + self.assertEqual(cur, value) + + def _check_expect(self, obj, expect): + for field, value in expect.items(): + path = field.strip().split(".") + if path[-1].startswith("__by_"): + self._check_set_by(obj, path, value) + else: + self._check_equals(obj, path, value) + + def send_request(self, url, payload, status_code=status.HTTP_200_OK): + """Post the payload to the url and return the response.""" + response = self.client.post( + url, data=payload, format="json", **self.authorization_header + ) + self.assertEqual(response.status_code, status_code) + return response diff --git a/netbox_diode_plugin/tests/v4.2.3/tests/test_updates_cases.json b/netbox_diode_plugin/tests/v4.2.3/tests/test_updates_cases.json new file mode 100644 index 0000000..7a1fe4b --- /dev/null +++ b/netbox_diode_plugin/tests/v4.2.3/tests/test_updates_cases.json @@ -0,0 +1,6106 @@ +[ + { + "name": "ipam_asn_1", + "object_type": "ipam.asn", + "lookup": {"asn": 555}, + "create_expect": { + "asn": 555, + "description": "ASN 555 Description", + "rir.name": "RIR 1" + }, + "create": { + "asn": { + "asn": "555", + "rir": {"name": "RIR 1"}, + "tenant": {"name": "Tenant 1"}, + "description": "ASN 555 Description", + "comments": "ASN 555 Comments", + "tags": [{"name": "Tag 1"}] + } + }, + "update": { + "asn": { + "asn": "555", + "rir": {"name": "RIR 1"}, + "tenant": {"name": "Tenant 1"}, + "description": "ASN 555 Description Updated", + "comments": "ASN 555 Comments", + "tags": [{"name": "Tag 1"}] + } + }, + "update_expect": { + "description": "ASN 555 Description Updated" + } + }, + { + "name": "ipam_asnrange_1", + "object_type": "ipam.asnrange", + "lookup": {"name": "ASN Range 1"}, + "create_expect": { + "name": "ASN Range 1", + "start": 1, + "end": 2, + "rir.name": "RIR 1" + }, + "create": { + "asn_range": { + "name": "ASN Range 1", + "slug": "asn-range-1", + "rir": {"name": "RIR 1"}, + "start": "1", + "end": "2", + "tenant": {"name": "Tenant 1"}, + "description": "ASN Range 1 Description", + "tags": [{"name": "Tag 1"}] + } + }, + "update": { + "asn_range": { + "name": "ASN Range 1", + "slug": "asn-range-1", + "rir": {"name": "RIR 1"}, + "start": "1", + "end": "2", + "tenant": {"name": "Tenant 1"}, + "description": "ASN Range 1 Description Updated", + "tags": [{"name": "Tag 1"}] + } + }, + "update_expect": { + "description": "ASN Range 1 Description Updated" + } + }, + { + "name": "ipam_aggregate_1", + "object_type": "ipam.aggregate", + "lookup": {"prefix": "182.82.82.0/24"}, + "create_expect": { + "prefix": "182.82.82.0/24", + "rir.name": "RIR 1", + "description": "Aggregate Description" + }, + "create": { + "aggregate": { + "prefix": "182.82.82.0/24", + "rir": {"name": "RIR 1"}, + "tenant": {"name": "Tenant 1"}, + "date_added": "2025-04-14T08:08:55Z", + "description": "Aggregate Description", + "comments": "Aggregate Comments", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "aggregate": { + "prefix": "182.82.82.0/24", + "rir": {"name": "RIR 1"}, + "tenant": {"name": "Tenant 1"}, + "date_added": "2025-04-14T08:08:55Z", + "description": "Aggregate Description Updated", + "comments": "Aggregate Comments", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Aggregate Description Updated" + } + }, + { + "name": "circuits_circuit_1", + "object_type": "circuits.circuit", + "lookup": {"cid": "Circuit 1"}, + "create_expect": { + "cid": "Circuit 1", + "provider.name": "Provider 1", + "type.name": "Circuit Type 1", + "description": "Circuit 1 Description" + }, + "create": { + "circuit": { + "cid": "Circuit 1", + "provider": {"name": "Provider 1"}, + "provider_account": { + "provider": {"name": "Provider 1"}, + "account": "account1" + }, + "type": {"name": "Circuit Type 1"}, + "status": "offline", + "tenant": {"name": "Tenant 1"}, + "install_date": "2025-04-14T00:00:00Z", + "termination_date": "2025-04-14T00:00:00Z", + "commit_rate": "10", + "description": "Circuit 1 Description", + "distance": 12.4, + "distance_unit": "ft", + "comments": "Circuit 1 Comments", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "circuit": { + "cid": "Circuit 1", + "provider": {"name": "Provider 1"}, + "provider_account": { + "provider": {"name": "Provider 1"}, + "account": "account1" + }, + "type": {"name": "Circuit Type 1"}, + "status": "offline", + "tenant": {"name": "Tenant 1"}, + "install_date": "2025-04-14T00:00:00Z", + "termination_date": "2025-04-14T00:00:00Z", + "commit_rate": "10", + "description": "Circuit 1 Description Updated", + "distance": 12.4, + "distance_unit": "ft", + "comments": "Circuit 1 Comments", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Circuit 1 Description Updated" + } + }, + { + "name": "circuits_circuitgroup_1", + "object_type": "circuits.circuitgroup", + "lookup": {"name": "Circuit Group 1"}, + "create_expect": { + "name": "Circuit Group 1", + "description": "Circuit Group 1 Description" + }, + "create": { + "circuit_group": { + "name": "Circuit Group 1", + "description": "Circuit Group 1 Description", + "tenant": {"name": "Tenant 1"}, + "tags": [{"name": "Tag 1"}] + } + }, + "update": { + "circuit_group": { + "name": "Circuit Group 1", + "description": "Circuit Group 1 Description Updated", + "tenant": {"name": "Tenant 1"}, + "tags": [{"name": "Tag 1"}] + } + }, + "update_expect": { + "description": "Circuit Group 1 Description Updated" + } + }, + { + "name": "circuits_circuitgroupassignment_1", + "object_type": "circuits.circuitgroupassignment", + "lookup": { + "group__name": "Circuit Group 1" + }, + "create_expect": { + "group.name": "Circuit Group 1", + "member.cid": "Circuit 1", + "priority": "tertiary" + }, + "create": { + "circuit_group_assignment": { + "group": {"name": "Circuit Group 1"}, + "member_circuit": { + "cid": "Circuit 1", + "type": {"name": "Circuit Type 1"}, + "provider": {"name": "Provider 1"} + }, + "priority": "tertiary" + } + }, + "update": { + "circuit_group_assignment": { + "group": {"name": "Circuit Group 1"}, + "member_circuit": { + "cid": "Circuit 1", + "type": {"name": "Circuit Type 1"}, + "provider": {"name": "Provider 1"} + }, + "priority": "secondary" + } + }, + "update_expect": { + "priority": "secondary" + } + }, + { + "name": "circuits_circuitgroupassignment_2", + "object_type": "circuits.circuitgroupassignment", + "lookup": { + "group__name": "Circuit Group 1" + }, + "create_expect": { + "group.name": "Circuit Group 1", + "member.cid": "Virtual Circuit 1", + "priority": "tertiary" + }, + "create": { + "circuit_group_assignment": { + "group": {"name": "Circuit Group 1"}, + "member_virtual_circuit": { + "cid": "Virtual Circuit 1", + "type": {"name": "Virtual Circuit Type 1"}, + "provider_network": { + "name": "Provider Network 1", + "provider": {"name": "Provider 1"} + } + }, + "priority": "tertiary" + } + }, + "update": { + "circuit_group_assignment": { + "group": {"name": "Circuit Group 1"}, + "member_virtual_circuit": { + "cid": "Virtual Circuit 1", + "type": {"name": "Virtual Circuit Type 1"}, + "provider_network": { + "name": "Provider Network 1", + "provider": {"name": "Provider 1"} + } + }, + "priority": "secondary" + } + }, + "update_expect": { + "priority": "secondary" + } + }, + { + "name": "circuits_circuittermination_1", + "object_type": "circuits.circuittermination", + "lookup": { + "circuit__cid": "Circuit 1", + "term_side": "A" + }, + "create_expect": { + "circuit.cid": "Circuit 1", + "term_side": "A", + "port_speed": 9600, + "description": "description" + }, + "create": { + "circuit_termination": { + "circuit": { + "cid": "Circuit 1", + "type": {"name": "Circuit Type 1"}, + "provider": {"name": "Provider 1"} + }, + "term_side": "A", + "termination_location": { + "name": "attic", + "site": {"name": "Site 1"} + }, + "port_speed": "9600", + "upstream_speed": "14400", + "xconnect_id": "xconnect.1", + "pp_info": "pp info", + "description": "description", + "mark_connected": true, + "tags": [{"name": "Tag 1"}] + } + }, + "update": { + "circuit_termination": { + "circuit": { + "cid": "Circuit 1", + "type": {"name": "Circuit Type 1"}, + "provider": {"name": "Provider 1"} + }, + "term_side": "A", + "termination_location": { + "name": "attic", + "site": {"name": "Site 1"} + }, + "port_speed": "9600", + "upstream_speed": "14400", + "xconnect_id": "xconnect.1", + "pp_info": "pp info", + "description": "description Updated", + "mark_connected": true, + "tags": [{"name": "Tag 1"}] + } + }, + "update_expect": { + "description": "description Updated" + } + }, + { + "name": "circuits_circuittermination_2", + "object_type": "circuits.circuittermination", + "lookup": { + "circuit__cid": "Circuit 1", + "term_side": "A" + }, + "create_expect": { + "circuit.cid": "Circuit 1", + "term_side": "A", + "port_speed": 9600, + "description": "description" + }, + "create": { + "circuit_termination": { + "circuit": { + "cid": "Circuit 1", + "type": {"name": "Circuit Type 1"}, + "provider": {"name": "Provider 1"} + }, + "term_side": "A", + "termination_provider_network": { + "provider": {"name": "Provider 1"}, + "name": "Provider Network 1", + "service_id": "service.1" + }, + "port_speed": "9600", + "upstream_speed": "14400", + "xconnect_id": "xconnect.1", + "pp_info": "pp info", + "description": "description", + "mark_connected": true, + "tags": [{"name": "Tag 1"}] + } + }, + "update": { + "circuit_termination": { + "circuit": { + "cid": "Circuit 1", + "type": {"name": "Circuit Type 1"}, + "provider": {"name": "Provider 1"} + }, + "term_side": "A", + "termination_provider_network": { + "provider": {"name": "Provider 1"}, + "name": "Provider Network 1", + "service_id": "service.1" + }, + "port_speed": "9600", + "upstream_speed": "14400", + "xconnect_id": "xconnect.1", + "pp_info": "pp info", + "description": "description Updated", + "mark_connected": true, + "tags": [{"name": "Tag 1"}] + } + }, + "update_expect": { + "description": "description Updated" + } + }, + { + "name": "circuits_circuittermination_3", + "object_type": "circuits.circuittermination", + "lookup": { + "circuit__cid": "Circuit 1", + "term_side": "A" + }, + "create_expect": { + "circuit.cid": "Circuit 1", + "term_side": "A", + "port_speed": 9600, + "description": "description" + }, + "create": { + "circuit_termination": { + "circuit": { + "cid": "Circuit 1", + "type": {"name": "Circuit Type 1"}, + "provider": {"name": "Provider 1"} + }, + "term_side": "A", + "termination_site": {"name": "Site 1"}, + "port_speed": "9600", + "upstream_speed": "14400", + "xconnect_id": "xconnect.1", + "pp_info": "pp info", + "description": "description", + "mark_connected": true, + "tags": [{"name": "Tag 1"}] + } + }, + "update": { + "circuit_termination": { + "circuit": { + "cid": "Circuit 1", + "type": {"name": "Circuit Type 1"}, + "provider": {"name": "Provider 1"} + }, + "term_side": "A", + "termination_site": {"name": "Site 1"}, + "port_speed": "9600", + "upstream_speed": "14400", + "xconnect_id": "xconnect.1", + "pp_info": "pp info", + "description": "description Updated", + "mark_connected": true, + "tags": [{"name": "Tag 1"}] + } + }, + "update_expect": { + "description": "description Updated" + } + }, + { + "name": "circuits_circuittype_1", + "object_type": "circuits.circuittype", + "lookup": {"name": "Circuit Type 1"}, + "create_expect": { + "name": "Circuit Type 1", + "description": "Circuit Type 1 Description" + }, + "create": { + "circuit_type": { + "name": "Circuit Type 1", + "slug": "circuit-type-1", + "color": "0000ff", + "description": "Circuit Type 1 Description", + "tags": [{"name": "Tag 1"}] + } + }, + "update": { + "circuit_type": { + "name": "Circuit Type 1", + "slug": "circuit-type-1", + "color": "0000ff", + "description": "Circuit Type 1 Description Updated", + "tags": [{"name": "Tag 1"}] + } + }, + "update_expect": { + "description": "Circuit Type 1 Description Updated" + } + }, + { + "name": "virtualization_cluster_1", + "object_type": "virtualization.cluster", + "lookup": {"name": "Cluster A"}, + "create_expect": { + "name": "Cluster A", + "type.name": "Cluster Type 1", + "description": "Cluster 1 Description" + }, + "create": { + "cluster": { + "name": "Cluster A", + "type": {"name": "Cluster Type 1"}, + "group": {"name": "Cluster Group 1"}, + "status": "active", + "tenant": {"name": "Tenant 1"}, + "scope_location": { + "name": "Location 1", + "site": {"name": "Site 1"} + }, + "description": "Cluster 1 Description", + "comments": "Cluster 1 Comments", + "tags": [{"name": "Tag 1"}] + } + }, + "update": { + "cluster": { + "name": "Cluster A", + "type": {"name": "Cluster Type 1"}, + "group": {"name": "Cluster Group 1"}, + "status": "active", + "tenant": {"name": "Tenant 1"}, + "scope_location": { + "name": "Location 1", + "site": {"name": "Site 1"} + }, + "description": "Cluster 1 Description Updated", + "comments": "Cluster 1 Comments", + "tags": [{"name": "Tag 1"}] + } + }, + "update_expect": { + "description": "Cluster 1 Description Updated" + } + }, + { + "name": "virtualization_cluster_2", + "object_type": "virtualization.cluster", + "lookup": {"name": "Cluster 2"}, + "create_expect": { + "name": "Cluster 2", + "type.name": "Cluster Type 1", + "description": "Cluster 1 Description" + }, + "create": { + "cluster": { + "name": "Cluster 2", + "type": {"name": "Cluster Type 1"}, + "group": {"name": "Cluster Group 1"}, + "status": "active", + "tenant": {"name": "Tenant 1"}, + "scope_region": {"name": "Region 1"}, + "description": "Cluster 1 Description", + "comments": "Cluster 1 Comments", + "tags": [{"name": "Tag 1"}] + } + }, + "update": { + "cluster": { + "name": "Cluster 2", + "type": {"name": "Cluster Type 1"}, + "group": {"name": "Cluster Group 1"}, + "status": "active", + "tenant": {"name": "Tenant 1"}, + "scope_region": {"name": "Region 1"}, + "description": "Cluster 1 Description Updated", + "comments": "Cluster 1 Comments", + "tags": [{"name": "Tag 1"}] + } + }, + "update_expect": { + "description": "Cluster 1 Description Updated" + } + }, + { + "name": "virtualization_cluster_3", + "object_type": "virtualization.cluster", + "lookup": {"name": "Cluster 3"}, + "create_expect": { + "name": "Cluster 3", + "type.name": "Cluster Type 1", + "description": "Cluster 1 Description" + }, + "create": { + "cluster": { + "name": "Cluster 3", + "type": {"name": "Cluster Type 1"}, + "group": {"name": "Cluster Group 1"}, + "status": "active", + "tenant": {"name": "Tenant 1"}, + "scope_site": {"name": "Site 1"}, + "description": "Cluster 1 Description", + "comments": "Cluster 1 Comments", + "tags": [{"name": "Tag 1"}] + } + }, + "update": { + "cluster": { + "name": "Cluster 3", + "type": {"name": "Cluster Type 1"}, + "group": {"name": "Cluster Group 1"}, + "status": "active", + "tenant": {"name": "Tenant 1"}, + "scope_site": {"name": "Site 1"}, + "description": "Cluster 1 Description Updated", + "comments": "Cluster 1 Comments", + "tags": [{"name": "Tag 1"}] + } + }, + "update_expect": { + "description": "Cluster 1 Description Updated" + } + }, + { + "name": "virtualization_cluster_4", + "object_type": "virtualization.cluster", + "lookup": {"name": "Cluster 4"}, + "create_expect": { + "name": "Cluster 4", + "type.name": "Cluster Type 1", + "description": "Cluster 1 Description" + }, + "create": { + "cluster": { + "name": "Cluster 4", + "type": {"name": "Cluster Type 1"}, + "group": {"name": "Cluster Group 1"}, + "status": "active", + "tenant": {"name": "Tenant 1"}, + "scope_site_group": {"name": "Site Group 1"}, + "description": "Cluster 1 Description", + "comments": "Cluster 1 Comments", + "tags": [{"name": "Tag 1"}] + } + }, + "update": { + "cluster": { + "name": "Cluster 4", + "type": {"name": "Cluster Type 1"}, + "group": {"name": "Cluster Group 1"}, + "status": "active", + "tenant": {"name": "Tenant 1"}, + "scope_site_group": {"name": "Site Group 1"}, + "description": "Cluster 1 Description Updated", + "comments": "Cluster 1 Comments", + "tags": [{"name": "Tag 1"}] + } + }, + "update_expect": { + "description": "Cluster 1 Description Updated" + } + }, + { + "name": "virtualization_clustergroup_1", + "object_type": "virtualization.clustergroup", + "lookup": {"name": "Cluster Group 1"}, + "create_expect": { + "name": "Cluster Group 1", + "description": "Cluster Group 1 Description" + }, + "create": { + "cluster_group": { + "name": "Cluster Group 1", + "description": "Cluster Group 1 Description", + "tags": [{"name": "Tag 1"}] + } + }, + "update": { + "cluster_group": { + "name": "Cluster Group 1", + "description": "Cluster Group 1 Description Updated", + "tags": [{"name": "Tag 1"}] + } + }, + "update_expect": { + "description": "Cluster Group 1 Description Updated" + } + }, + { + "name": "virtualization_clustertype_1", + "object_type": "virtualization.clustertype", + "lookup": {"name": "Cluster Type 1"}, + "create_expect": { + "name": "Cluster Type 1", + "description": "Cluster Type 1 Description" + }, + "create": { + "cluster_type": { + "name": "Cluster Type 1", + "description": "Cluster Type 1 Description", + "tags": [{"name": "Tag 1"}] + } + }, + "update": { + "cluster_type": { + "name": "Cluster Type 1", + "description": "Cluster Type 1 Description Updated", + "tags": [{"name": "Tag 1"}] + } + }, + "update_expect": { + "description": "Cluster Type 1 Description Updated" + } + }, + { + "name": "dcim_consoleport_1", + "object_type": "dcim.consoleport", + "lookup": {"name": "Console Port 1"}, + "create_expect": { + "name": "Console Port 1", + "description": "Console Port 1 Description" + }, + "create": { + "console_port": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module_bay": { + "name": "Module Bay 1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + } + }, + "module_type": { + "manufacturer": { + "name": "Manufacturer 1" + }, + "model": "Module Type 1" + } + }, + "name": "Console Port 1", + "label": "Console Port 1 Label", + "type": "db-25", + "speed": "1200", + "description": "Console Port 1 Description", + "mark_connected": true, + "tags": [{"name": "Tag 1"}] + } + }, + "update": { + "console_port": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module_bay": { + "name": "Module Bay 1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + } + }, + "module_type": { + "manufacturer": { + "name": "Manufacturer 1" + }, + "model": "Module Type 1" + } + }, + "name": "Console Port 1", + "label": "Console Port 1 Label", + "type": "db-25", + "speed": "1200", + "description": "Console Port 1 Description Updated", + "mark_connected": true, + "tags": [{"name": "Tag 1"}] + } + }, + "update_expect": { + "description": "Console Port 1 Description Updated" + } + }, + { + "name": "dcim_consoleserverport_1", + "object_type": "dcim.consoleserverport", + "lookup": {"name": "Console Server Port 1"}, + "create_expect": { + "name": "Console Server Port 1", + "description": "Console Server Port 1 Description" + }, + "create": { + "console_server_port": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module_bay": { + "name": "Module Bay 1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + } + }, + "module_type": { + "manufacturer": { + "name": "Manufacturer 1" + }, + "model": "Module Type 1" + } + }, + "name": "Console Server Port 1", + "label": "Console Server Port 1 Label", + "type": "db-25", + "speed": "1200", + "description": "Console Server Port 1 Description", + "mark_connected": true, + "tags": [{"name": "Tag 1"}] + } + }, + "update": { + "console_server_port": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module_bay": { + "name": "Module Bay 1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + } + }, + "module_type": { + "manufacturer": { + "name": "Manufacturer 1" + }, + "model": "Module Type 1" + } + }, + "name": "Console Server Port 1", + "label": "Console Server Port 1 Label", + "type": "db-25", + "speed": "1200", + "description": "Console Server Port 1 Description Updated", + "mark_connected": true, + "tags": [{"name": "Tag 1"}] + } + }, + "update_expect": { + "description": "Console Server Port 1 Description Updated" + } + }, + { + "name": "tenancy_contact_1", + "object_type": "tenancy.contact", + "lookup": {"name": "Contact 1"}, + "create_expect": { + "name": "Contact 1", + "group.name": "Contact Group 1", + "description": "Contact 1 Description" + }, + "create": { + "contact": { + "group": {"name": "Contact Group 1"}, + "name": "Contact 1", + "title": "Contact 1 Title", + "phone": "1234567890", + "email": "contact1@example.com", + "address": "1234 Main St, Anytown, USA", + "link": "https://example.com", + "description": "Contact 1 Description", + "comments": "Contact 1 Comments", + "tags": [{"name": "Tag 1"}] + } + }, + "update": { + "contact": { + "group": {"name": "Contact Group 1"}, + "name": "Contact 1", + "title": "Contact 1 Title", + "phone": "1234567890", + "email": "contact1@example.com", + "address": "1234 Main St, Anytown, USA", + "link": "https://example.com", + "description": "Contact 1 Description Updated", + "comments": "Contact 1 Comments", + "tags": [{"name": "Tag 1"}] + } + }, + "update_expect": { + "description": "Contact 1 Description Updated" + } + }, + { + "name": "tenancy_contact_groups_migrate_down", + "object_type": "tenancy.contact", + "lookup": {"name": "Contact 2"}, + "create_expect": { + "name": "Contact 2", + "group.name": "Contact Group 1", + "description": "Contact 2 Description" + }, + "create": { + "contact": { + "groups": [{"name": "Contact Group 1"}], + "name": "Contact 2", + "title": "Contact 2 Title", + "phone": "1234567890", + "email": "contact2@example.com", + "address": "1235 Main St, Anytown, USA", + "link": "https://example.com/2", + "description": "Contact 2 Description", + "comments": "Contact 2 Comments", + "tags": [{"name": "Tag 1"}] + } + }, + "update": { + "contact": { + "groups": [{"name": "Contact Group 1"}], + "name": "Contact 2", + "title": "Contact 2 Title", + "phone": "1234567890", + "email": "contact2@example.com", + "address": "1234 Main St, Anytown, USA", + "link": "https://example.com/2", + "description": "Contact 2 Description Updated", + "comments": "Contact 2 Comments", + "tags": [{"name": "Tag 1"}] + } + }, + "update_expect": { + "description": "Contact 2 Description Updated" + } + }, + { + "name": "tenancy_contactassignment_1", + "object_type": "tenancy.contactassignment", + "lookup": { + "contact__name": "Contact 1", + "role__name": "Contact Role 1" + }, + "create_expect": { + "contact.name": "Contact 1", + "role.name": "Contact Role 1", + "priority": "primary" + }, + "create": { + "contact_assignment": { + "contact": { + "name": "Contact 1", + "group": {"name": "Contact Group 1"}, + "title": "Contact 1 Title" + }, + "role": {"name": "Contact Role 1"}, + "priority": "primary", + "tags": [{"name": "Tag 1"}], + "object_site": {"name": "Site 1"} + } + }, + "update": { + "contact_assignment": { + "contact": { + "name": "Contact 1", + "group": {"name": "Contact Group 1"}, + "title": "Contact 1 Title" + }, + "role": {"name": "Contact Role 1"}, + "priority": "secondary", + "tags": [{"name": "Tag 1"}], + "object_site": {"name": "Site 1"}, + "comments": "ignored field from the future" + } + }, + "update_expect": { + "priority": "secondary" + } + }, + { + "name": "tenancy_contactgroup_1", + "object_type": "tenancy.contactgroup", + "lookup": {"name": "Contact Group 1"}, + "create_expect": { + "name": "Contact Group 1", + "description": "Contact Group 1 Description" + }, + "create": { + "contact_group": { + "name": "Contact Group 1", + "parent": {"name": "Contact Group 2"}, + "description": "Contact Group 1 Description", + "tags": [{"name": "Tag 1"}] + } + }, + "update": { + "contact_group": { + "name": "Contact Group 1", + "parent": {"name": "Contact Group 2"}, + "description": "Contact Group 1 Description Updated", + "tags": [{"name": "Tag 1"}] + } + }, + "update_expect": { + "description": "Contact Group 1 Description Updated" + } + }, + { + "name": "tenancy_contactrole_1", + "object_type": "tenancy.contactrole", + "lookup": {"name": "Contact Role 1"}, + "create_expect": { + "name": "Contact Role 1", + "description": "Contact Role 1 Description" + }, + "create": { + "contact_role": { + "name": "Contact Role 1", + "description": "Contact Role 1 Description", + "tags": [{"name": "Tag 1"}] + } + }, + "update": { + "contact_role": { + "name": "Contact Role 1", + "description": "Contact Role 1 Description Updated", + "tags": [{"name": "Tag 1"}] + } + }, + "update_expect": { + "description": "Contact Role 1 Description Updated" + } + }, + { + "name": "dcim_device_1", + "object_type": "dcim.device", + "lookup": {"name": "Device ABC"}, + "create_expect": { + "name": "Device ABC", + "device_type.manufacturer.name": "Cisco", + "device_type.model": "C2960S", + "role.name": "Device Role 1", + "description": "Device 1 Description" + }, + "create": { + "device": { + "name": "Device ABC", + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "role": {"name": "Device Role 1"}, + "tenant": {"name": "Tenant 1"}, + "platform": {"name": "Platform 1"}, + "serial": "1234567890", + "asset_tag": "asset.1", + "site": {"name": "Site 1"}, + "location": { + "name": "Location 1", + "site": {"name": "Site 1"} + }, + "rack": { + "name": "Rack 1", + "site": {"name": "Site 1"}, + "location": { + "name": "Location 1", + "site": {"name": "Site 1"} + } + }, + "position": 1.0, + "face": "front", + "status": "active", + "airflow": "bottom-to-top", + "description": "Device 1 Description", + "comments": "Device 1 Comments", + "tags": [{"name": "Tag 1"}] + } + }, + "update": { + "device": { + "name": "Device ABC", + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "role": {"name": "Device Role 1"}, + "tenant": {"name": "Tenant 1"}, + "platform": {"name": "Platform 1"}, + "serial": "1234567890", + "asset_tag": "asset.1", + "site": {"name": "Site 1"}, + "location": { + "name": "Location 1", + "site": {"name": "Site 1"} + }, + "rack": { + "name": "Rack 1", + "site": {"name": "Site 1"}, + "location": { + "name": "Location 1", + "site": {"name": "Site 1"} + } + }, + "position": 1.0, + "face": "front", + "status": "active", + "airflow": "bottom-to-top", + "description": "Device 1 Description Updated", + "comments": "Device 1 Comments", + "tags": [{"name": "Tag 1"}] + } + }, + "update_expect": { + "description": "Device 1 Description Updated" + } + }, + { + "name": "dcim_devicebay_1", + "object_type": "dcim.devicebay", + "lookup": { + "device__name": "Device 1", + "name": "Device Bay 1" + }, + "create_expect": { + "device.name": "Device 1", + "name": "Device Bay 1", + "description": "Device Bay 1 Description" + }, + "create": { + "device_bay": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C3P0", + "subdevice_role": "parent" + }, + "site": {"name": "Site 1"} + }, + "name": "Device Bay 1", + "label": "Device Bay 1 Label", + "description": "Device Bay 1 Description", + "installed_device": { + "name": "Device 2", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "tags": [{"name": "Tag 1"}] + } + }, + "update": { + "device_bay": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C3P0", + "subdevice_role": "parent" + }, + "site": {"name": "Site 1"} + }, + "name": "Device Bay 1", + "label": "Device Bay 1 Label", + "description": "Device Bay 1 Description Updated", + "installed_device": { + "name": "Device 2", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "tags": [{"name": "Tag 1"}] + } + }, + "update_expect": { + "description": "Device Bay 1 Description Updated" + } + }, + { + "name": "dcim_devicerole_1", + "object_type": "dcim.devicerole", + "lookup": {"name": "Core Router"}, + "create_expect": { + "name": "Core Router", + "description": "Primary network routing device" + }, + "create": { + "device_role": { + "name": "Core Router", + "slug": "core-router", + "color": "ff0000", + "vm_role": true, + "description": "Primary network routing device", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "device_role": { + "name": "Core Router", + "slug": "core-router", + "color": "ff0000", + "vm_role": true, + "description": "Primary network routing device Updated", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Primary network routing device Updated" + } + }, + { + "name": "dcim_devicetype_1", + "object_type": "dcim.devicetype", + "lookup": { + "manufacturer__name": "Cisco", + "model": "Catalyst 9300" + }, + "create_expect": { + "manufacturer.name": "Cisco", + "model": "Catalyst 9300", + "description": "Enterprise Series Switch" + }, + "create": { + "device_type": { + "manufacturer": {"name": "Cisco"}, + "default_platform": {"name": "IOS-XE"}, + "model": "Catalyst 9300", + "slug": "catalyst-9300", + "part_number": "C9300-48P-E", + "u_height": 1.0, + "exclude_from_utilization": false, + "is_full_depth": true, + "subdevice_role": "parent", + "airflow": "front-to-rear", + "weight": 14.5, + "weight_unit": "lb", + "description": "Enterprise Series Switch", + "comments": "High-performance access switch", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "device_type": { + "manufacturer": {"name": "Cisco"}, + "default_platform": {"name": "IOS-XE"}, + "model": "Catalyst 9300", + "slug": "catalyst-9300", + "part_number": "C9300-48P-E", + "u_height": 1.0, + "exclude_from_utilization": false, + "is_full_depth": true, + "subdevice_role": "parent", + "airflow": "front-to-rear", + "weight": 14.5, + "weight_unit": "lb", + "description": "Enterprise Series Switch Updated", + "comments": "High-performance access switch", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Enterprise Series Switch Updated" + } + }, + { + "name": "ipam_fhrpgroup_1", + "object_type": "ipam.fhrpgroup", + "lookup": {"name": "HSRP Group 10"}, + "create_expect": { + "name": "HSRP Group 10", + "protocol": "hsrp", + "group_id": 10, + "description": "Core Router HSRP Group" + }, + "create": { + "fhrp_group": { + "name": "HSRP Group 10", + "protocol": "hsrp", + "group_id": "10", + "auth_type": "md5", + "auth_key": "secretkey123", + "description": "Core Router HSRP Group", + "comments": "Primary gateway redundancy group", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "fhrp_group": { + "name": "HSRP Group 10", + "protocol": "hsrp", + "group_id": "10", + "auth_type": "md5", + "auth_key": "secretkey123", + "description": "Core Router HSRP Group Updated", + "comments": "Primary gateway redundancy group", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Core Router HSRP Group Updated" + } + }, + { + "name": "ipam_fhrpgroupassignment_1", + "object_type": "ipam.fhrpgroupassignment", + "lookup": { + "group__name": "HSRP Group 10" + }, + "create_expect": { + "group.name": "HSRP Group 10", + "priority": 100 + }, + "create": { + "fhrp_group_assignment": { + "group": { + "name": "HSRP Group 10", + "protocol": "hsrp", + "group_id": "10" + }, + "interface_interface": { + "device": { + "name": "Device 1", + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "role": {"name": "Device Role 1"}, + "site": {"name": "Site 1"} + }, + "name": "GigabitEthernet1/0/1", + "type": "1000base-t", + "enabled": true + }, + "priority": 100 + } + }, + "update": { + "fhrp_group_assignment": { + "group": { + "name": "HSRP Group 10", + "protocol": "hsrp", + "group_id": "10" + }, + "interface_interface": { + "device": { + "name": "Device 1", + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "role": {"name": "Device Role 1"}, + "site": {"name": "Site 1"} + }, + "name": "GigabitEthernet1/0/1", + "type": "1000base-t", + "enabled": true + }, + "priority": 200 + } + }, + "update_expect": { + "priority": 200 + } + }, + { + "name": "dcim_frontport_1", + "object_type": "dcim.frontport", + "lookup": { + "device__name": "Device 1", + "name": "Front Port 1" + }, + "create_expect": { + "device.name": "Device 1", + "name": "Front Port 1", + "description": "Front fiber port" + }, + "create": { + "front_port": { + "device": { + "name": "Device 1", + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "role": {"name": "Device Role 1"}, + "site": {"name": "Site 1"} + }, + "module": { + "device": { + "name": "Device 1", + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "role": {"name": "Device Role 1"}, + "site": {"name": "Site 1"} + }, + "module_bay": { + "name": "Module Bay 1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + } + }, + "module_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S-MODULE" + } + }, + "name": "Front Port 1", + "label": "FP1", + "type": "lc-apc", + "color": "0000ff", + "rear_port": { + "device": { + "name": "Device 1", + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "role": {"name": "Device Role 1"}, + "site": {"name": "Site 1"} + }, + "name": "Rear Port 1", + "type": "lc-apc" + }, + "rear_port_position": "1", + "description": "Front fiber port", + "mark_connected": true, + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "front_port": { + "device": { + "name": "Device 1", + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "role": {"name": "Device Role 1"}, + "site": {"name": "Site 1"} + }, + "module": { + "device": { + "name": "Device 1", + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "role": {"name": "Device Role 1"}, + "site": {"name": "Site 1"} + }, + "module_bay": { + "name": "Module Bay 1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + } + }, + "module_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S-MODULE" + } + }, + "name": "Front Port 1", + "label": "FP1", + "type": "lc-apc", + "color": "0000ff", + "rear_port": { + "device": { + "name": "Device 1", + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "role": {"name": "Device Role 1"}, + "site": {"name": "Site 1"} + }, + "name": "Rear Port 1", + "type": "lc-apc" + }, + "rear_port_position": "1", + "description": "Front fiber port Updated", + "mark_connected": true, + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Front fiber port Updated" + } + }, + { + "name": "vpn_ikepolicy_1", + "object_type": "vpn.ikepolicy", + "lookup": {"name": "IKE-POLICY-1"}, + "create_expect": { + "name": "IKE-POLICY-1", + "version": 2, + "description": "Main IPSec IKE Policy" + }, + "create": { + "ike_policy": { + "name": "IKE-POLICY-1", + "description": "Main IPSec IKE Policy", + "version": "2", + "preshared_key": "secretPSK123!", + "comments": "Primary IKE policy for VPN tunnels", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}], + "proposals": [ + { + "name": "IKE-PROPOSAL-1", + "description": "AES-256 with SHA-256", + "authentication_method": "preshared-keys", + "encryption_algorithm": "aes-256-cbc", + "authentication_algorithm": "hmac-sha256", + "group": "14", + "sa_lifetime": "28800" + } + ] + } + }, + "update": { + "ike_policy": { + "name": "IKE-POLICY-1", + "description": "Main IPSec IKE Policy Updated", + "version": "2", + "preshared_key": "secretPSK123!", + "comments": "Primary IKE policy for VPN tunnels", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}], + "proposals": [ + { + "name": "IKE-PROPOSAL-1", + "description": "AES-256 with SHA-256", + "authentication_method": "preshared-keys", + "encryption_algorithm": "aes-256-cbc", + "authentication_algorithm": "hmac-sha256", + "group": "14", + "sa_lifetime": "28800" + } + ] + } + }, + "update_expect": { + "description": "Main IPSec IKE Policy Updated" + } + }, + { + "name": "vpn_ikeproposal_1", + "object_type": "vpn.ikeproposal", + "lookup": {"name": "IKE-PROPOSAL-2"}, + "create_expect": { + "name": "IKE-PROPOSAL-2", + "description": "High Security IKE Proposal" + }, + "create": { + "ike_proposal": { + "name": "IKE-PROPOSAL-2", + "description": "High Security IKE Proposal", + "authentication_method": "certificates", + "encryption_algorithm": "aes-256-gcm", + "authentication_algorithm": "hmac-sha512", + "group": "21", + "sa_lifetime": "86400", + "comments": "Enhanced security proposal for critical VPNs", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "ike_proposal": { + "name": "IKE-PROPOSAL-2", + "description": "High Security IKE Proposal Updated", + "authentication_method": "certificates", + "encryption_algorithm": "aes-256-gcm", + "authentication_algorithm": "hmac-sha512", + "group": "21", + "sa_lifetime": "86400", + "comments": "Enhanced security proposal for critical VPNs", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "High Security IKE Proposal Updated" + } + }, + { + "name": "ipam_ipaddress_1", + "object_type": "ipam.ipaddress", + "lookup": {"address": "192.168.100.1/24"}, + "create_expect": { + "address": "192.168.100.1/24", + "vrf.name": "PROD-VRF", + "description": "Production VIP Address" + }, + "create": { + "ip_address": { + "address": "192.168.100.1/24", + "vrf": { + "name": "PROD-VRF", + "rd": "65000:1" + }, + "tenant": {"name": "Tenant 1"}, + "status": "active", + "role": "vip", + "assigned_object_interface": { + "device": { + "name": "Device 1", + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "role": {"name": "Device Role 1"}, + "site": {"name": "Site 1"} + }, + "name": "GigabitEthernet1/0/1", + "type": "1000base-t" + }, + "nat_inside": { + "address": "10.0.0.1/24" + }, + "dns_name": "prod-vip.example.com", + "description": "Production VIP Address", + "comments": "Primary virtual IP for load balancing", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "ip_address": { + "address": "192.168.100.1/24", + "vrf": { + "name": "PROD-VRF", + "rd": "65000:1" + }, + "tenant": {"name": "Tenant 1"}, + "status": "active", + "role": "vip", + "assigned_object_interface": { + "device": { + "name": "Device 1", + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "role": {"name": "Device Role 1"}, + "site": {"name": "Site 1"} + }, + "name": "GigabitEthernet1/0/1", + "type": "1000base-t" + }, + "nat_inside": { + "address": "10.0.0.1/24" + }, + "dns_name": "prod-vip.example.com", + "description": "Production VIP Address Updated", + "comments": "Primary virtual IP for load balancing", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Production VIP Address Updated" + } + }, + { + "name": "ipam_iprange_1", + "object_type": "ipam.iprange", + "lookup": { + "start_address": "10.100.0.1", + "end_address": "10.100.0.254" + }, + "create_expect": { + "start_address": "10.100.0.1/32", + "end_address": "10.100.0.254/32", + "description": "Production Server IP Range" + }, + "create": { + "ip_range": { + "start_address": "10.100.0.1", + "end_address": "10.100.0.254", + "vrf": { + "name": "PROD-VRF", + "rd": "65000:1" + }, + "tenant": {"name": "Tenant 1"}, + "status": "active", + "role": { + "name": "Server Pool", + "slug": "server-pool" + }, + "description": "Production Server IP Range", + "comments": "Allocated for production server deployments", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}], + "mark_utilized": true + } + }, + "update": { + "ip_range": { + "start_address": "10.100.0.1", + "end_address": "10.100.0.254", + "vrf": { + "name": "PROD-VRF", + "rd": "65000:1" + }, + "tenant": {"name": "Tenant 1"}, + "status": "active", + "role": { + "name": "Server Pool", + "slug": "server-pool" + }, + "description": "Production Server IP Range Updated", + "comments": "Allocated for production server deployments", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}], + "mark_utilized": true + } + }, + "update_expect": { + "description": "Production Server IP Range Updated" + } + }, + { + "name": "vpn_ipsecpolicy_1", + "object_type": "vpn.ipsecpolicy", + "lookup": {"name": "IPSEC-POLICY-1"}, + "create_expect": { + "name": "IPSEC-POLICY-1", + "description": "Site-to-Site VPN Policy" + }, + "create": { + "ip_sec_policy": { + "name": "IPSEC-POLICY-1", + "description": "Site-to-Site VPN Policy", + "pfs_group": "14", + "comments": "High-security IPSec policy for site-to-site VPN", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}], + "proposals": [ + { + "name": "IPSEC-PROPOSAL-1", + "description": "AES-256-GCM with ESP", + "encryption_algorithm": "aes-256-gcm", + "sa_lifetime_seconds": "28800", + "sa_lifetime_data": "28800", + "comments": "Strong encryption proposal for VPN tunnels" + } + ] + } + }, + "update": { + "ip_sec_policy": { + "name": "IPSEC-POLICY-1", + "description": "Site-to-Site VPN Policy Updated", + "pfs_group": "14", + "comments": "High-security IPSec policy for site-to-site VPN", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}], + "proposals": [ + { + "name": "IPSEC-PROPOSAL-1", + "description": "AES-256-GCM with ESP", + "encryption_algorithm": "aes-256-gcm", + "sa_lifetime_seconds": "28800", + "sa_lifetime_data": "28800", + "comments": "Strong encryption proposal for VPN tunnels" + } + ] + } + }, + "update_expect": { + "description": "Site-to-Site VPN Policy Updated" + } + }, + { + "name": "vpn_ipsecprofile_1", + "object_type": "vpn.ipsecprofile", + "lookup": {"name": "IPSEC-PROFILE-1"}, + "create_expect": { + "name": "IPSEC-PROFILE-1", + "description": "Remote Access VPN Profile" + }, + "create": { + "ip_sec_profile": { + "name": "IPSEC-PROFILE-1", + "description": "Remote Access VPN Profile", + "mode": "esp", + "ike_policy": { + "name": "IKE-POLICY-1", + "version": "2", + "preshared_key": "secretkey123" + }, + "ipsec_policy": { + "name": "IPSEC-POLICY-1", + "description": "Strong encryption policy", + "pfs_group": "14" + }, + "comments": "Standard IPSec profile for remote access VPN tunnels", + "tags": [{"name": "VPN"}, {"name": "Remote-Access"}] + } + }, + "update": { + "ip_sec_profile": { + "name": "IPSEC-PROFILE-1", + "description": "Remote Access VPN Profile Updated", + "mode": "esp", + "ike_policy": { + "name": "IKE-POLICY-1", + "version": "2", + "preshared_key": "secretkey123" + }, + "ipsec_policy": { + "name": "IPSEC-POLICY-1", + "description": "Strong encryption policy", + "pfs_group": "14" + }, + "comments": "Standard IPSec profile for remote access VPN tunnels", + "tags": [{"name": "VPN"}, {"name": "Remote-Access"}] + } + }, + "update_expect": { + "description": "Remote Access VPN Profile Updated" + } + }, + { + "name": "vpn_ipsecproposal_1", + "object_type": "vpn.ipsecproposal", + "lookup": {"name": "IPSec-Proposal-AES256"}, + "create_expect": { + "name": "IPSec-Proposal-AES256", + "description": "High security IPSec proposal using AES-256-GCM" + }, + "create": { + "ip_sec_proposal": { + "name": "IPSec-Proposal-AES256", + "description": "High security IPSec proposal using AES-256-GCM", + "encryption_algorithm": "aes-256-gcm", + "authentication_algorithm": "hmac-sha512", + "sa_lifetime_seconds": "28800", + "sa_lifetime_data": "42949", + "comments": "Used for critical infrastructure VPNs", + "tags": [ + { + "name": "high-security", + "slug": "high-security", + "color": "0000ff" + }, + { + "name": "production", + "slug": "production", + "color": "0000ff" + } + ] + } + }, + "update": { + "ip_sec_proposal": { + "name": "IPSec-Proposal-AES256", + "description": "High security IPSec proposal using AES-256-GCM Updated", + "encryption_algorithm": "aes-256-gcm", + "authentication_algorithm": "hmac-sha512", + "sa_lifetime_seconds": "28800", + "sa_lifetime_data": "42949", + "comments": "Used for critical infrastructure VPNs", + "tags": [ + { + "name": "high-security", + "slug": "high-security", + "color": "0000ff" + }, + { + "name": "production", + "slug": "production", + "color": "0000ff" + } + ] + } + }, + "update_expect": { + "description": "High security IPSec proposal using AES-256-GCM Updated" + } + }, + { + "name": "dcim_interface_1", + "object_type": "dcim.interface", + "lookup": {"name": "GigabitEthernet1/0/1"}, + "create_expect": { + "name": "GigabitEthernet1/0/1", + "label": "Core Link 1", + "tagged_vlans.all.__by_vid": [101, 102] + }, + "create": { + "interface": { + "name": "GigabitEthernet1/0/1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module_bay": { + "name": "Module Bay 1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + } + }, + "module_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S-MODULE" + } + }, + "label": "Core Link 1", + "type": "1000base-t", + "enabled": true, + "bridge": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "Bridge1", + "type": "bridge" + }, + "lag": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "Port-Channel2", + "type": "lag" + }, + "mtu": "9000", + "primary_mac_address": { + "mac_address": "00:11:22:33:44:55" + }, + "speed": "1000000000", + "duplex": "full", + "wwn": "50:01:43:80:00:00:00:00", + "vrf": { + "name": "PROD-VRF", + "rd": "65000:1" + }, + "description": "Core network interface", + "mode": "tagged", + "mark_connected": true, + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}], + "mgmt_only": false, + "poe_mode": "pse", + "poe_type": "type3-ieee802.3bt", + "untagged_vlan": { + "vid": 100, + "name": "Data VLAN", + "status": "active" + }, + "vlan_translation_policy": { + "name": "Customer Translation Policy", + "description": "VLAN translation for customer traffic" + }, + "vdcs": [ + { + "name": "VDC1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "identifier": 1, + "status": "active", + "description": "Primary VDC" + } + ], + "tagged_vlans": [ + { + "vid": 101, + "name": "Voice VLAN", + "status": "active" + }, + { + "vid": 102, + "name": "Data VLAN", + "status": "active" + } + ] + } + }, + "update": { + "interface": { + "name": "GigabitEthernet1/0/1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module_bay": { + "name": "Module Bay 1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + } + }, + "module_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S-MODULE" + } + }, + "label": "Core Link 1", + "type": "1000base-t", + "enabled": true, + "bridge": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "Bridge1", + "type": "bridge" + }, + "lag": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "Port-Channel2", + "type": "lag" + }, + "mtu": "9000", + "primary_mac_address": { + "mac_address": "00:11:22:33:44:55" + }, + "speed": "1000000000", + "duplex": "full", + "wwn": "50:01:43:80:00:00:00:00", + "vrf": { + "name": "PROD-VRF", + "rd": "65000:1" + }, + "description": "Core network interface", + "mode": "q-in-q", + "mark_connected": true, + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}, {"name": "Tag 3"}], + "mgmt_only": false, + "poe_mode": "pse", + "poe_type": "type3-ieee802.3bt", + "untagged_vlan": { + "vid": 100, + "name": "Data VLAN", + "status": "active" + }, + "vlan_translation_policy": { + "name": "Customer Translation Policy", + "description": "VLAN translation for customer traffic" + }, + "vdcs": [ + { + "name": "VDC1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "identifier": 1, + "status": "active", + "description": "Primary VDC" + } + ], + "tagged_vlans": [ + { + "vid": 101, + "name": "Voice VLAN", + "status": "active" + }, + { + "vid": 102, + "name": "Data VLAN", + "status": "active" + } + ] + } + }, + "update_expect": { + "tags.all.__by_name": ["Tag 1", "Tag 2", "Tag 3"] + } + }, + { + "name": "dcim_interface_2", + "object_type": "dcim.interface", + "lookup": {"name": "WirelessGigabitEthernet1/0/1"}, + "create_expect": { + "name": "WirelessGigabitEthernet1/0/1", + "label": "Core Link 1" + }, + "create": { + "interface": { + "name": "WirelessGigabitEthernet1/0/1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module_bay": { + "name": "Module Bay 1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + } + }, + "module_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S-MODULE" + } + }, + "label": "Core Link 1", + "type": "other-wireless", + "enabled": true, + "bridge": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "Bridge1", + "type": "bridge" + }, + "lag": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "Port-Channel2", + "type": "lag" + }, + "mtu": "9000", + "primary_mac_address": { + "mac_address": "00:11:22:33:44:55" + }, + "speed": "1000000000", + "duplex": "full", + "wwn": "50:01:43:80:00:00:00:00", + "rf_role": "ap", + "rf_channel": "2.4g-1-2412-22", + "tx_power": "20", + "wireless_lans": [ + { + "ssid": "Corp-Secure", + "description": "Corporate secure wireless network", + "group": { + "name": "Corporate Networks", + "slug": "corporate-networks" + }, + "status": "active", + "vlan": { + "vid": 800, + "name": "Production Servers" + }, + "tenant": {"name": "Tenant 1"} + } + ], + "vrf": { + "name": "PROD-VRF", + "rd": "65000:1" + }, + "description": "Core network interface", + "mode": "access", + "mark_connected": true, + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}], + "mgmt_only": false, + "untagged_vlan": { + "vid": 900, + "name": "Data VLAN", + "status": "active" + }, + "vlan_translation_policy": { + "name": "Customer Translation Policy", + "description": "VLAN translation for customer traffic" + }, + "vdcs": [ + { + "name": "VDC1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "identifier": 1, + "status": "active", + "description": "Primary VDC" + } + ] + } + }, + "update": { + "interface": { + "name": "WirelessGigabitEthernet1/0/1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module_bay": { + "name": "Module Bay 1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + } + }, + "module_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S-MODULE" + } + }, + "label": "Core Link 1", + "type": "other-wireless", + "enabled": true, + "bridge": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "Bridge1", + "type": "bridge" + }, + "lag": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "Port-Channel2", + "type": "lag" + }, + "mtu": "9000", + "primary_mac_address": { + "mac_address": "00:11:22:33:44:55" + }, + "speed": "1000000000", + "duplex": "full", + "wwn": "50:01:43:80:00:00:00:00", + "rf_role": "ap", + "rf_channel": "2.4g-1-2412-22", + "tx_power": "20", + "wireless_lans": [ + { + "ssid": "Corp-Secure", + "description": "Corporate secure wireless network", + "group": { + "name": "Corporate Networks", + "slug": "corporate-networks" + }, + "status": "active", + "vlan": { + "vid": 800, + "name": "Production Servers" + }, + "tenant": {"name": "Tenant 1"} + } + ], + "vrf": { + "name": "PROD-VRF", + "rd": "65000:1" + }, + "description": "Core network interface", + "mode": "access", + "mark_connected": true, + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}, {"name": "Tag 3"}], + "mgmt_only": false, + "untagged_vlan": { + "vid": 900, + "name": "Data VLAN", + "status": "active" + }, + "vlan_translation_policy": { + "name": "Customer Translation Policy", + "description": "VLAN translation for customer traffic" + }, + "vdcs": [ + { + "name": "VDC1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "identifier": 1, + "status": "active", + "description": "Primary VDC" + } + ] + } + }, + "update_expect": { + "tags.all.__by_name": ["Tag 1", "Tag 2", "Tag 3"] + } + }, + { + "name": "dcim_interface_3", + "object_type": "dcim.interface", + "lookup": {"name": "VirtualGigabitEthernet1/0/1"}, + "create_expect": { + "name": "VirtualGigabitEthernet1/0/1", + "label": "Core Link 1" + }, + "create": { + "interface": { + "name": "VirtualGigabitEthernet1/0/1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "parent": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "Port-Channel1", + "type": "1000base-t" + }, + "module": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module_bay": { + "name": "Module Bay 1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + } + }, + "module_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S-MODULE" + } + }, + "label": "Core Link 1", + "type": "virtual", + "enabled": true, + "bridge": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "Bridge1", + "type": "bridge" + }, + "mtu": "9000", + "primary_mac_address": { + "mac_address": "00:11:22:33:44:55" + }, + "speed": "1000000000", + "duplex": "full", + "wwn": "50:01:43:80:00:00:00:00", + "vrf": { + "name": "PROD-VRF", + "rd": "65000:1" + }, + "description": "Core network interface", + "mode": "q-in-q", + "mark_connected": false, + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}], + "mgmt_only": false, + "untagged_vlan": { + "vid": 444, + "name": "Data VLAN", + "status": "active" + }, + "qinq_svlan": { + "vid": 2000, + "name": "Service VLAN", + "status": "active" + }, + "vlan_translation_policy": { + "name": "Customer Translation Policy", + "description": "VLAN translation for customer traffic" + }, + "vdcs": [ + { + "name": "VDC1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "identifier": 1, + "status": "active", + "description": "Primary VDC" + } + ] + } + }, + "update": { + "interface": { + "name": "VirtualGigabitEthernet1/0/1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "parent": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "Port-Channel1", + "type": "1000base-t" + }, + "module": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module_bay": { + "name": "Module Bay 1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + } + }, + "module_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S-MODULE" + } + }, + "label": "Core Link 1", + "type": "virtual", + "enabled": true, + "bridge": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "Bridge1", + "type": "bridge" + }, + "mtu": "9000", + "primary_mac_address": { + "mac_address": "00:11:22:33:44:55" + }, + "speed": "1000000000", + "duplex": "full", + "wwn": "50:01:43:80:00:00:00:00", + "vrf": { + "name": "PROD-VRF", + "rd": "65000:1" + }, + "description": "Core network interface", + "mode": "q-in-q", + "mark_connected": false, + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}, {"name": "Tag 3"}], + "mgmt_only": false, + "untagged_vlan": { + "vid": 444, + "name": "Data VLAN", + "status": "active" + }, + "qinq_svlan": { + "vid": 2000, + "name": "Service VLAN", + "status": "active" + }, + "vlan_translation_policy": { + "name": "Customer Translation Policy", + "description": "VLAN translation for customer traffic" + }, + "vdcs": [ + { + "name": "VDC1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "identifier": 1, + "status": "active", + "description": "Primary VDC" + } + ] + } + }, + "update_expect": { + "tags.all.__by_name": ["Tag 1", "Tag 2", "Tag 3"] + } + }, + { + "name": "vpn_l2vpn_1", + "object_type": "vpn.l2vpn", + "lookup": {"name": "Customer-VPLS-1"}, + "create_expect": { + "name": "Customer-VPLS-1", + "type": "vpls", + "description": "Customer VPLS service for multi-site connectivity" + }, + "create": { + "l2vpn": { + "name": "Customer-VPLS-1", + "slug": "customer-vpls-1", + "type": "vpls", + "identifier": "65000", + "import_targets": [ + { + "name": "65000:1001", + "description": "Primary import target" + }, + { + "name": "65000:1002", + "description": "Secondary import target" + } + ], + "export_targets": [ + { + "name": "65000:1003", + "description": "Primary export target" + } + ], + "description": "Customer VPLS service for multi-site connectivity", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "l2vpn": { + "name": "Customer-VPLS-1", + "slug": "customer-vpls-1", + "type": "vpls", + "identifier": "65000", + "import_targets": [ + { + "name": "65000:1001", + "description": "Primary import target" + }, + { + "name": "65000:1002", + "description": "Secondary import target" + } + ], + "export_targets": [ + { + "name": "65000:1003", + "description": "Primary export target" + } + ], + "description": "Customer VPLS service for multi-site connectivity Updated", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Customer VPLS service for multi-site connectivity Updated" + } + }, + { + "name": "dcim_inventoryitem_1", + "object_type": "dcim.inventoryitem", + "lookup": {"name": "Power Supply 1"}, + "create_expect": { + "name": "Power Supply 1", + "description": "715W AC Power Supply" + }, + "create": { + "inventory_item": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "parent": { + "name": "Chassis 1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + } + }, + "name": "Power Supply 1", + "label": "PSU1", + "role": { + "name": "Power Supply", + "color": "00ff00" + }, + "manufacturer": {"name": "Cisco"}, + "part_id": "PWR-C1-715WAC", + "serial": "ABC123XYZ", + "asset_tag": "ASSET-001", + "discovered": true, + "description": "715W AC Power Supply", + "status": "active", + "component_power_port": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "PSU1 Power Port", + "type": "iec-60320-c14", + "maximum_draw": 715, + "allocated_draw": 500, + "description": "Power input port for PSU1" + }, + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "inventory_item": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "parent": { + "name": "Chassis 1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + } + }, + "name": "Power Supply 1", + "label": "PSU1", + "role": { + "name": "Power Supply", + "color": "00ff00" + }, + "manufacturer": {"name": "Cisco"}, + "part_id": "PWR-C1-715WAC", + "serial": "ABC123XYZ", + "asset_tag": "ASSET-001", + "discovered": true, + "description": "715W AC Power Supply Updated", + "status": "active", + "component_power_port": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "PSU1 Power Port", + "type": "iec-60320-c14", + "maximum_draw": 715, + "allocated_draw": 500, + "description": "Power input port for PSU1" + }, + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "715W AC Power Supply Updated" + } + }, + { + "name": "dcim_inventoryitemrole_1", + "object_type": "dcim.inventoryitemrole", + "lookup": {"name": "Line Card"}, + "create_expect": { + "name": "Line Card", + "description": "Network switch line card module" + }, + "create": { + "inventory_item_role": { + "name": "Line Card", + "slug": "line-card", + "color": "0000ff", + "description": "Network switch line card module", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "inventory_item_role": { + "name": "Line Card", + "slug": "line-card", + "color": "0000ff", + "description": "Network switch line card module Updated", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Network switch line card module Updated" + } + }, + { + "name": "vpn_l2vpn_1", + "object_type": "vpn.l2vpn", + "lookup": {"name": "Customer-VPLS-1"}, + "create_expect": { + "name": "Customer-VPLS-1", + "type": "vpls", + "identifier": 65000, + "description": "Customer VPLS service for multi-site connectivity" + }, + "create": { + "l2vpn": { + "name": "Customer-VPLS-1", + "slug": "customer-vpls-1", + "type": "vpls", + "identifier": "65000", + "import_targets": [ + { + "name": "65000:1001", + "description": "Primary import target" + }, + { + "name": "65000:1002", + "description": "Secondary import target" + } + ], + "export_targets": [ + { + "name": "65000:1003", + "description": "Primary export target" + } + ], + "description": "Customer VPLS service for multi-site connectivity", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "l2vpn": { + "name": "Customer-VPLS-1", + "slug": "customer-vpls-1", + "type": "vpls", + "identifier": "65000", + "import_targets": [ + { + "name": "65000:1001", + "description": "Primary import target" + }, + { + "name": "65000:1002", + "description": "Secondary import target" + } + ], + "export_targets": [ + { + "name": "65000:1003", + "description": "Primary export target" + } + ], + "description": "Customer VPLS service for multi-site connectivity Updated", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Customer VPLS service for multi-site connectivity Updated" + } + }, + { + "name": "vpn_l2vpntermination_1", + "object_type": "vpn.l2vpntermination", + "lookup": { + "l2vpn__name": "Customer-VPLS-1" + }, + "create_expect": { + "l2vpn.name": "Customer-VPLS-1", + "l2vpn.type": "vpls" + }, + "create": { + "l2vpn_termination": { + "l2vpn": { + "name": "Customer-VPLS-1", + "type": "vpls", + "identifier": "65000" + }, + "assigned_object_interface": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "GigabitEthernet1/0/1", + "type": "1000base-t", + "enabled": true + }, + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "l2vpn_termination": { + "l2vpn": { + "name": "Customer-VPLS-1", + "type": "vpls", + "identifier": "65000" + }, + "assigned_object_interface": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "GigabitEthernet1/0/1", + "type": "1000base-t", + "enabled": true + }, + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}, {"name": "Tag 3"}] + } + }, + "update_expect": { + "tags.all.__by_name": ["Tag 1", "Tag 2", "Tag 3"] + } + }, + { + "name": "dcim_location_1", + "object_type": "dcim.location", + "lookup": {"name": "Data Center East Wing"}, + "create_expect": { + "name": "Data Center East Wing", + "description": "East wing of the main data center facility" + }, + "create": { + "location": { + "name": "Data Center East Wing", + "slug": "dc-east-wing", + "site": {"name": "Site 1"}, + "parent": { + "name": "Main Data Center", + "slug": "main-dc", + "site": {"name": "Site 1"} + }, + "status": "active", + "tenant": {"name": "Tenant 1"}, + "facility": "Building A, Floor 3", + "description": "East wing of the main data center facility", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "location": { + "name": "Data Center East Wing", + "slug": "dc-east-wing", + "site": {"name": "Site 1"}, + "parent": { + "name": "Main Data Center", + "slug": "main-dc", + "site": {"name": "Site 1"} + }, + "status": "active", + "tenant": {"name": "Tenant 1"}, + "facility": "Building A, Floor 3", + "description": "East wing of the main data center facility Updated", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "East wing of the main data center facility Updated" + } + }, + { + "name": "dcim_macaddress_1", + "object_type": "dcim.macaddress", + "lookup": {"mac_address": "00:1A:2B:3C:4D:5E"}, + "create_expect": { + "mac_address": "00:1A:2B:3C:4D:5E", + "description": "Primary management interface MAC" + }, + "create": { + "mac_address": { + "mac_address": "00:1A:2B:3C:4D:5E", + "assigned_object_interface": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "GigabitEthernet1/0/1", + "type": "1000base-t", + "enabled": true + }, + "description": "Primary management interface MAC", + "comments": "Reserved for network management access", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "mac_address": { + "mac_address": "00:1A:2B:3C:4D:5E", + "assigned_object_interface": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "GigabitEthernet1/0/1", + "type": "1000base-t", + "enabled": true + }, + "description": "Primary management interface MAC Updated", + "comments": "Reserved for network management access", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Primary management interface MAC Updated" + } + }, + { + "name": "dcim_manufacturer_1", + "object_type": "dcim.manufacturer", + "lookup": {"name": "Arista Networks"}, + "create_expect": { + "name": "Arista Networks", + "slug": "arista-networks", + "description": "Leading provider of cloud networking solutions" + }, + "create": { + "manufacturer": { + "name": "Arista Networks", + "slug": "arista-networks", + "description": "Leading provider of cloud networking solutions", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + + } + }, + "update": { + "manufacturer": { + "name": "Arista Networks", + "slug": "arista-networks", + "description": "Leading provider of cloud networking solutions Updated", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Leading provider of cloud networking solutions Updated" + } + }, + { + "name": "dcim_module_1", + "object_type": "dcim.module", + "lookup": {"asset_tag": "MOD-001"}, + "create_expect": { + "status": "active", + "serial": "MOD123XYZ", + "asset_tag": "MOD-001", + "description": "Stacking module for switch interconnect" + }, + "create": { + "module": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module_bay": { + "name": "Module Bay 1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + } + }, + "module_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S-STACK" + }, + "status": "active", + "serial": "MOD123XYZ", + "asset_tag": "MOD-001", + "description": "Stacking module for switch interconnect", + "comments": "Primary stack member module", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "module": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module_bay": { + "name": "Module Bay 1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + } + }, + "module_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S-STACK" + }, + "status": "active", + "serial": "MOD123XYZ", + "asset_tag": "MOD-001", + "description": "Stacking module for switch interconnect Updated", + "comments": "Primary stack member module", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Stacking module for switch interconnect Updated" + } + }, + { + "name": "dcim_modulebay_1", + "object_type": "dcim.modulebay", + "lookup": {"name": "Stack Module Bay 2"}, + "create_expect": { + "name": "Stack Module Bay 2", + "description": "Secondary stacking module bay" + }, + "create": { + "module_bay": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "Stack Module Bay 2", + "module": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S-STACK" + }, + "module_bay": { + "name": "Module Bay 1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + } + } + + }, + "label": "STACK-2", + "position": "Rear", + "description": "Secondary stacking module bay", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "module_bay": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "Stack Module Bay 2", + "module": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S-STACK" + }, + "module_bay": { + "name": "Module Bay 1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + } + } + + }, + "label": "STACK-2", + "position": "Rear", + "description": "Secondary stacking module bay Updated", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Secondary stacking module bay Updated" + } + }, + { + "name": "dcim_moduletype_1", + "object_type": "dcim.moduletype", + "lookup": { + "manufacturer__name": "Cisco", + "model": "C9300-NM-8X" + }, + "create_expect": { + "manufacturer.name": "Cisco", + "model": "C9300-NM-8X", + "description": "Catalyst 9300 8 x 10GE Network Module" + }, + "create": { + "module_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C9300-NM-8X", + "part_number": "C9300-NM-8X=", + "airflow": "front-to-rear", + "weight": 0.7, + "weight_unit": "kg", + "description": "Catalyst 9300 8 x 10GE Network Module", + "comments": "Hot-swappable uplink module for C9300 series switches", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "module_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C9300-NM-8X", + "part_number": "C9300-NM-8X=", + "airflow": "front-to-rear", + "weight": 0.7, + "weight_unit": "kg", + "description": "Catalyst 9300 8 x 10GE Network Module Updated", + "comments": "Hot-swappable uplink module for C9300 series switches", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Catalyst 9300 8 x 10GE Network Module Updated" + } + }, + { + "name": "dcim_platform_1", + "object_type": "dcim.platform", + "lookup": {"name": "Cisco IOS-XE"}, + "create_expect": { + "name": "Cisco IOS-XE", + "manufacturer.name": "Cisco", + "description": "Enterprise-class IOS operating system for Catalyst switches and ISR routers" + }, + "create": { + "platform": { + "name": "Cisco IOS-XE", + "slug": "cisco-ios-xe", + "manufacturer": {"name": "Cisco"}, + "description": "Enterprise-class IOS operating system for Catalyst switches and ISR routers", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "platform": { + "name": "Cisco IOS-XE", + "slug": "cisco-ios-xe", + "manufacturer": {"name": "Cisco"}, + "description": "Enterprise-class IOS operating system for Catalyst switches and ISR routers Updated", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Enterprise-class IOS operating system for Catalyst switches and ISR routers Updated" + } + }, + { + "name": "dcim_powerfeed_1", + "object_type": "dcim.powerfeed", + "lookup": {"name": "Power Feed A1"}, + "create_expect": { + "name": "Power Feed A1", + "power_panel.name": "Panel A", + "description": "Primary power feed for network equipment rack" + }, + "create": { + "power_feed": { + "power_panel": { + "site": {"name": "Site 1"}, + "name": "Panel A" + }, + "rack": { + "name": "Rack 1", + "site": {"name": "Site 1"}, + "location": { + "name": "Location 1", + "site": {"name": "Site 1"} + } + }, + "name": "Power Feed A1", + "status": "active", + "type": "primary", + "supply": "ac", + "phase": "three-phase", + "voltage": "208", + "amperage": "30", + "max_utilization": "80", + "mark_connected": true, + "description": "Primary power feed for network equipment rack", + "tenant": {"name": "Tenant 1"}, + "comments": "Connected to UPS system A with redundant backup", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "power_feed": { + "power_panel": { + "site": {"name": "Site 1"}, + "name": "Panel A" + }, + "rack": { + "name": "Rack 1", + "site": {"name": "Site 1"}, + "location": { + "name": "Location 1", + "site": {"name": "Site 1"} + } + }, + "name": "Power Feed A1", + "status": "active", + "type": "primary", + "supply": "ac", + "phase": "three-phase", + "voltage": "208", + "amperage": "30", + "max_utilization": "80", + "mark_connected": true, + "description": "Primary power feed for network equipment rack Updated", + "tenant": {"name": "Tenant 1"}, + "comments": "Connected to UPS system A with redundant backup", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Primary power feed for network equipment rack Updated" + } + }, + { + "name": "dcim_poweroutlet_1", + "object_type": "dcim.poweroutlet", + "lookup": { + "device__name": "Device 1", + "name": "PSU1-Outlet1" + }, + "create_expect": { + "device.name": "Device 1", + "name": "PSU1-Outlet1", + "description": "Power outlet for network switch PSU" + }, + "create": { + "power_outlet": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module_type": { + "manufacturer": {"name": "Cisco"}, + "model": "PWR-C1-715WAC" + }, + "module_bay": { + "name": "Module Bay 1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + } + } + }, + "name": "PSU1-Outlet1", + "label": "OUT-1", + "type": "iec-60320-c13", + "color": "0000ff", + "power_port": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "PSU1" + }, + "feed_leg": "A", + "description": "Power outlet for network switch PSU", + "mark_connected": true, + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "power_outlet": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module_type": { + "manufacturer": {"name": "Cisco"}, + "model": "PWR-C1-715WAC" + }, + "module_bay": { + "name": "Module Bay 1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + } + } + }, + "name": "PSU1-Outlet1", + "label": "OUT-1", + "type": "iec-60320-c13", + "color": "0000ff", + "power_port": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "PSU1" + }, + "feed_leg": "A", + "description": "Power outlet for network switch PSU Updated", + "mark_connected": true, + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Power outlet for network switch PSU Updated" + } + }, + { + "name": "dcim_powerpanel_1", + "object_type": "dcim.powerpanel", + "lookup": { + "site__name": "Site 1", + "name": "Panel A" + }, + "create_expect": { + "site.name": "Site 1", + "name": "Panel A", + "description": "Main power distribution panel" + }, + "create": { + "power_panel": { + "site": {"name": "Site 1"}, + "location": { + "name": "Location 1", + "site": {"name": "Site 1"} + }, + "name": "Panel A", + "description": "Main power distribution panel", + "comments": "Primary power distribution for data center", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "power_panel": { + "site": {"name": "Site 1"}, + "location": { + "name": "Location 1", + "site": {"name": "Site 1"} + }, + "name": "Panel A", + "description": "Main power distribution panel Updated", + "comments": "Primary power distribution for data center", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Main power distribution panel Updated" + } + }, + { + "name": "dcim_powerport_1", + "object_type": "dcim.powerport", + "lookup": { + "device__name": "Device 1", + "name": "PSU1" + }, + "create_expect": { + "device.name": "Device 1", + "name": "PSU1", + "description": "Primary power supply unit" + }, + "create": { + "power_port": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module_type": { + "manufacturer": {"name": "Cisco"}, + "model": "PWR-C1-715WAC" + }, + "module_bay": { + "name": "Module Bay 1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + } + } + }, + "name": "PSU1", + "label": "PSU-1", + "type": "iec-60320-c14", + "maximum_draw": 715, + "allocated_draw": 650, + "description": "Primary power supply unit", + "mark_connected": true, + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "power_port": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module_type": { + "manufacturer": {"name": "Cisco"}, + "model": "PWR-C1-715WAC" + }, + "module_bay": { + "name": "Module Bay 1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + } + } + }, + "name": "PSU1", + "label": "PSU-1", + "type": "iec-60320-c14", + "maximum_draw": 715, + "allocated_draw": 650, + "description": "Primary power supply unit Updated", + "mark_connected": true, + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Primary power supply unit Updated" + } + }, + { + "name": "ipam_prefix_1", + "object_type": "ipam.prefix", + "lookup": {"prefix": "10.100.0.0/16"}, + "create_expect": { + "prefix": "10.100.0.0/16", + "description": "Production network address space" + }, + "create": { + "prefix": { + "prefix": "10.100.0.0/16", + "vrf": { + "name": "PROD-VRF", + "rd": "65000:1" + }, + "scope_site": {"name": "Site 1"}, + "tenant": {"name": "Tenant 1"}, + "vlan": { + "name": "Production VLAN", + "vid": "112" + }, + "status": "active", + "role": { + "name": "Production", + "slug": "production" + }, + "is_pool": true, + "mark_utilized": true, + "description": "Production network address space", + "comments": "Primary address allocation for production services", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "prefix": { + "prefix": "10.100.0.0/16", + "vrf": { + "name": "PROD-VRF", + "rd": "65000:1" + }, + "scope_site": {"name": "Site 1"}, + "tenant": {"name": "Tenant 1"}, + "vlan": { + "name": "Production VLAN", + "vid": "112" + }, + "status": "active", + "role": { + "name": "Production", + "slug": "production" + }, + "is_pool": true, + "mark_utilized": true, + "description": "Production network address space Updated", + "comments": "Primary address allocation for production services", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Production network address space Updated" + } + }, + { + "name": "circuits_provider_1", + "object_type": "circuits.provider", + "lookup": {"name": "Level 3 Communications"}, + "create_expect": { + "name": "Level 3 Communications", + "slug": "level3", + "description": "Global Tier 1 Internet Service Provider" + }, + "create": { + "provider": { + "name": "Level 3 Communications", + "slug": "level3", + "description": "Global Tier 1 Internet Service Provider", + "comments": "Primary transit provider for data center connectivity", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}], + "accounts": [ + { + "provider": {"name": "Level 3 Communications"}, + "name": "East Coast Account", + "account": "L3-12345", + "description": "East Coast regional services account", + "comments": "Managed through regional NOC" + }, + { + "provider": {"name": "Level 3 Communications"}, + "name": "West Coast Account", + "account": "L3-67890", + "description": "West Coast regional services account", + "comments": "Managed through regional NOC" + } + ], + "asns": [ + { + "asn": "3356", + "rir": {"name": "ARIN"}, + "tenant": {"name": "Tenant 1"}, + "description": "Level 3 Global ASN", + "comments": "Primary transit ASN" + } + ] + } + }, + "update": { + "provider": { + "name": "Level 3 Communications", + "slug": "level3", + "description": "Global Tier 1 Internet Service Provider Updated", + "comments": "Primary transit provider for data center connectivity", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}], + "accounts": [ + { + "provider": {"name": "Level 3 Communications"}, + "name": "East Coast Account", + "account": "L3-12345", + "description": "East Coast regional services account", + "comments": "Managed through regional NOC" + }, + { + "provider": {"name": "Level 3 Communications"}, + "name": "West Coast Account", + "account": "L3-67890", + "description": "West Coast regional services account", + "comments": "Managed through regional NOC" + } + ], + "asns": [ + { + "asn": "3356", + "rir": {"name": "ARIN"}, + "tenant": {"name": "Tenant 1"}, + "description": "Level 3 Global ASN", + "comments": "Primary transit ASN" + } + ] + } + }, + "update_expect": { + "description": "Global Tier 1 Internet Service Provider Updated" + } + }, + { + "name": "circuits_provideraccount_1", + "object_type": "circuits.provideraccount", + "lookup": { + "provider__name": "Level 3 Communications", + "account": "ACCT-12345" + }, + "create_expect": { + "provider.name": "Level 3 Communications", + "account": "ACCT-12345", + "description": "Primary enterprise account" + }, + "create": { + "provider_account": { + "provider": {"name": "Level 3 Communications"}, + "account": "ACCT-12345", + "name": "L3 Enterprise", + "description": "Primary enterprise account", + "comments": "Global services contract", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "provider_account": { + "provider": {"name": "Level 3 Communications"}, + "account": "ACCT-12345", + "name": "L3 Enterprise", + "description": "Primary enterprise account Updated", + "comments": "Global services contract", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Primary enterprise account Updated" + } + }, + { + "name": "circuits_providernetwork_1", + "object_type": "circuits.providernetwork", + "lookup": {"name": "Global MPLS Network"}, + "create_expect": { + "name": "Global MPLS Network", + "provider.name": "Level 3 Communications", + "description": "Global MPLS backbone network" + }, + "create": { + "provider_network": { + "provider": {"name": "Level 3 Communications"}, + "name": "Global MPLS Network", + "service_id": "L3-MPLS-001", + "description": "Global MPLS backbone network", + "comments": "Primary enterprise MPLS network infrastructure", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "provider_network": { + "provider": {"name": "Level 3 Communications"}, + "name": "Global MPLS Network", + "service_id": "L3-MPLS-001", + "description": "Global MPLS backbone network Updated", + "comments": "Primary enterprise MPLS network infrastructure", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Global MPLS backbone network Updated" + } + }, + { + "name": "ipam_rir_1", + "object_type": "ipam.rir", + "lookup": {"name": "ARIN"}, + "create_expect": { + "name": "ARIN", + "description": "American Registry for Internet Numbers" + }, + "create": { + "rir": { + "name": "ARIN", + "slug": "arin", + "is_private": false, + "description": "American Registry for Internet Numbers", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "rir": { + "name": "ARIN", + "slug": "arin", + "is_private": false, + "description": "American Registry for Internet Numbers Updated", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "American Registry for Internet Numbers Updated" + } + }, + { + "name": "dcim_rack_1", + "object_type": "dcim.rack", + "lookup": {"asset_tag": "RACK-009"}, + "create_expect": { + "name": "Rack ZZ", + "site.name": "Site 1", + "description": "Standard 42U server rack" + }, + "create": { + "rack": { + "name": "Rack ZZ", + "facility_id": "FAC-001", + "site": {"name": "Site 1"}, + "location": {"name": "Data Center East Wing", "site": {"name": "Site 1"}}, + "tenant": {"name": "Tenant 1"}, + "status": "active", + "role": { + "name": "Server Rack", + "slug": "server-rack", + "color": "0000ff", + "description": "Primary server rack role" + }, + "serial": "RACK123XYZ", + "asset_tag": "RACK-009", + "rack_type": { + "manufacturer": {"name": "Manufacturer 1"}, + "model": "R2000", + "slug": "r2000", + "form_factor": "4-post-cabinet" + }, + "form_factor": "4-post-cabinet", + "width": "19", + "u_height": "42", + "starting_unit": "1", + "desc_units": false, + "airflow": "front-to-rear", + "description": "Standard 42U server rack", + "comments": "Located in primary data center", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "rack": { + "name": "Rack ZZ", + "facility_id": "FAC-001", + "site": {"name": "Site 1"}, + "location": {"name": "Data Center East Wing", "site": {"name": "Site 1"}}, + "tenant": {"name": "Tenant 1"}, + "status": "active", + "role": { + "name": "Server Rack", + "slug": "server-rack", + "color": "0000ff", + "description": "Primary server rack role" + }, + "serial": "RACK123XYZ", + "asset_tag": "RACK-009", + "rack_type": { + "manufacturer": {"name": "Manufacturer 1"}, + "model": "R2000", + "slug": "r2000", + "form_factor": "4-post-cabinet" + }, + "form_factor": "4-post-cabinet", + "width": "19", + "u_height": "42", + "starting_unit": "1", + "desc_units": false, + "mounting_depth": "30", + "airflow": "front-to-rear", + "description": "Standard 42U server rack Updated", + "comments": "Located in primary data center", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Standard 42U server rack Updated" + } + }, + { + "name": "dcim_rackrole_1", + "object_type": "dcim.rackrole", + "lookup": {"name": "Network Equipment"}, + "create_expect": { + "name": "Network Equipment", + "description": "Dedicated racks for network infrastructure" + }, + "create": { + "rack_role": { + "name": "Network Equipment", + "slug": "network-equipment", + "color": "0000ff", + "description": "Dedicated racks for network infrastructure", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "rack_role": { + "name": "Network Equipment", + "slug": "network-equipment", + "color": "0000ff", + "description": "Dedicated racks for network infrastructure Updated", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Dedicated racks for network infrastructure Updated" + } + }, + { + "name": "dcim_racktype_1", + "object_type": "dcim.racktype", + "lookup": { + "manufacturer__name": "Manufacturer 1", + "model": "R2000" + }, + "create_expect": { + "manufacturer.name": "Manufacturer 1", + "model": "R2000", + "description": "Standard 42U server rack" + }, + "create": { + "rack_type": { + "manufacturer": {"name": "Manufacturer 1"}, + "model": "R2000", + "slug": "r2000", + "description": "Standard 42U server rack", + "form_factor": "4-post-cabinet", + "width": "19", + "u_height": "42", + "starting_unit": "1", + "desc_units": false, + "outer_width": "24", + "outer_depth": "36", + "outer_unit": "in", + "weight": "350.5", + "max_weight": "1000", + "weight_unit": "lb", + "mounting_depth": "30", + "comments": "Standard enterprise rack configuration", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "rack_type": { + "manufacturer": {"name": "Manufacturer 1"}, + "model": "R2000", + "slug": "r2000", + "description": "Standard 42U server rack Updated", + "form_factor": "4-post-cabinet", + "width": "19", + "u_height": "42", + "starting_unit": "1", + "desc_units": false, + "outer_width": "24", + "outer_depth": "36", + "outer_unit": "in", + "weight": "350.5", + "max_weight": "1000", + "weight_unit": "lb", + "mounting_depth": "30", + "comments": "Standard enterprise rack configuration", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Standard 42U server rack Updated" + } + }, + { + "name": "dcim_rearport_1", + "object_type": "dcim.rearport", + "lookup": { + "device__name": "Device 1", + "name": "Rear Port 1" + }, + "create_expect": { + "device.name": "Device 1", + "name": "Rear Port 1", + "description": "Rear fiber port" + }, + "create": { + "rear_port": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module_bay": { + "name": "Module Bay 1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + } + }, + "module_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S-MODULE" + } + }, + "name": "Rear Port 1", + "label": "RP1", + "type": "lc-apc", + "color": "0000ff", + "positions": "1", + "description": "Rear fiber port", + "mark_connected": true, + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "rear_port": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "module_bay": { + "name": "Module Bay 1", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + } + }, + "module_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S-MODULE" + } + }, + "name": "Rear Port 1", + "label": "RP1", + "type": "lc-apc", + "color": "0000ff", + "positions": "1", + "description": "Rear fiber port Updated", + "mark_connected": true, + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Rear fiber port Updated" + } + }, + { + "name": "dcim_region_1", + "object_type": "dcim.region", + "lookup": {"name": "North America"}, + "create_expect": { + "name": "North America", + "parent.name": "Global", + "description": "North American Region" + }, + "create": { + "region": { + "name": "North America", + "slug": "north-america", + "parent": { + "name": "Global", + "slug": "global", + "description": "Global Region", + "tags": [{"name": "Tag 1"}] + }, + "description": "North American Region", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "region": { + "name": "North America", + "slug": "north-america", + "parent": { + "name": "Global", + "slug": "global", + "description": "Global Region", + "tags": [{"name": "Tag 1"}] + }, + "description": "North American Region Updated", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "North American Region Updated" + } + }, + { + "name": "ipam_role_1", + "object_type": "ipam.role", + "lookup": {"name": "Network Administrator"}, + "create_expect": { + "name": "Network Administrator", + "weight": 1000, + "description": "Primary network administration role" + }, + "create": { + "role": { + "name": "Network Administrator", + "slug": "network-admin", + "weight": "1000", + "description": "Primary network administration role", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "role": { + "name": "Network Administrator", + "slug": "network-admin", + "weight": "1000", + "description": "Primary network administration role Updated", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Primary network administration role Updated" + } + }, + { + "name": "ipam_routetarget_1", + "object_type": "ipam.routetarget", + "lookup": {"name": "65000:1001"}, + "create_expect": { + "name": "65000:1001", + "tenant.name": "Tenant 1", + "description": "Primary route target for MPLS VPN" + }, + "create": { + "route_target": { + "name": "65000:1001", + "tenant": {"name": "Tenant 1"}, + "description": "Primary route target for MPLS VPN", + "comments": "Used for customer VPN service", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "route_target": { + "name": "65000:1001", + "tenant": {"name": "Tenant 1"}, + "description": "Primary route target for MPLS VPN Updated", + "comments": "Used for customer VPN service", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Primary route target for MPLS VPN Updated" + } + }, + { + "name": "ipam_service_1", + "object_type": "ipam.service", + "lookup": {"name": "Web Server"}, + "create_expect": { + "name": "Web Server", + "protocol": "tcp", + "ports": [80, 443], + "description": "Primary web server service" + }, + "create": { + "service": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "Web Server", + "protocol": "tcp", + "ports": ["80", "443"], + "description": "Primary web server service", + "comments": "Handles HTTPS traffic for main website", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}], + "ipaddresses": [ + { + "address": "192.168.1.100/24", + "status": "active", + "dns_name": "web.example.com" + } + ] + } + }, + "update": { + "service": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "Web Server", + "protocol": "tcp", + "ports": ["80", "443"], + "description": "Primary web server service Updated", + "comments": "Handles HTTPS traffic for main website", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}], + "ipaddresses": [ + { + "address": "192.168.1.100/24", + "status": "active", + "dns_name": "web.example.com" + } + ] + } + }, + "update_expect": { + "description": "Primary web server service Updated" + } + }, + { + "name": "ipam_service_2_migrate_parent_object_device_down", + "object_type": "ipam.service", + "lookup": {"name": "Web Server 3"}, + "create_expect": { + "name": "Web Server 3", + "protocol": "tcp", + "ports": [80, 443], + "description": "Primary web server service", + "device.name": "Device 1" + }, + "create": { + "service": { + "parent_object_device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "Web Server 3", + "protocol": "tcp", + "ports": ["80", "443"], + "description": "Primary web server service", + "comments": "Handles HTTPS traffic for main website", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}], + "ipaddresses": [ + { + "address": "192.168.1.100/24", + "status": "active", + "dns_name": "web.example.com" + } + ] + } + }, + "update": { + "service": { + "parent_object_device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "Web Server 3", + "protocol": "tcp", + "ports": ["80", "443"], + "description": "Primary web server service Updated", + "comments": "Handles HTTPS traffic for main website", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}], + "ipaddresses": [ + { + "address": "192.168.1.100/24", + "status": "active", + "dns_name": "web.example.com" + } + ] + } + }, + "update_expect": { + "description": "Primary web server service Updated", + "device.name": "Device 1" + } + }, + { + "name": "ipam_service_3_migrate_parent_object_virtual_machine_down", + "object_type": "ipam.service", + "lookup": {"name": "Web Server 4"}, + "create_expect": { + "name": "Web Server 4", + "protocol": "tcp", + "ports": [80, 443], + "description": "Primary web server service", + "virtual_machine.name": "Virtual Machine 1" + }, + "create": { + "service": { + "parent_object_virtual_machine": { + "name": "Virtual Machine 1", + "status": "active", + "role": {"name": "Web Server"}, + "site": {"name": "Site 1"} + }, + "name": "Web Server 4", + "protocol": "tcp", + "ports": ["80", "443"], + "description": "Primary web server service", + "comments": "Handles HTTPS traffic for main website", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}], + "ipaddresses": [ + { + "address": "192.168.1.100/24", + "status": "active", + "dns_name": "web.example.com" + } + ] + } + }, + "update": { + "service": { + "parent_object_virtual_machine": { + "name": "Virtual Machine 1", + "status": "active", + "role": {"name": "Web Server"}, + "site": {"name": "Site 1"} + }, + "name": "Web Server 4", + "protocol": "tcp", + "ports": ["80", "443"], + "description": "Primary web server service Updated", + "comments": "Handles HTTPS traffic for main website", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}], + "ipaddresses": [ + { + "address": "192.168.1.100/24", + "status": "active", + "dns_name": "web.example.com" + } + ] + } + }, + "update_expect": { + "description": "Primary web server service Updated", + "virtual_machine.name": "Virtual Machine 1" + } + }, + { + "name": "dcim_site_1", + "object_type": "dcim.site", + "lookup": {"name": "Data Center West"}, + "create_expect": { + "name": "Data Center West", + "region.name": "North America", + "group.name": "Primary Data Centers", + "description": "Primary West Coast Data Center" + }, + "create": { + "site": { + "name": "Data Center West", + "slug": "dc-west", + "status": "active", + "region": { + "name": "North America", + "slug": "north-america" + }, + "group": { + "name": "Primary Data Centers", + "slug": "primary-dcs" + }, + "tenant": {"name": "Tenant 1"}, + "facility": "Building 7", + "time_zone": "America/Los_Angeles", + "description": "Primary West Coast Data Center", + "physical_address": "123 Tech Drive, San Jose, CA 95134", + "shipping_address": "Receiving Dock 3, 123 Tech Drive, San Jose, CA 95134", + "latitude": 37.3382, + "longitude": -121.8863, + "comments": "24x7 access requires security clearance", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}], + "asns": [ + { + "asn": "555", + "rir": {"name": "RIR 1"}, + "tenant": {"name": "Tenant 1"}, + "description": "ASN 555 Description", + "comments": "ASN 555 Comments", + "tags": [{"name": "Tag 1"}] + } + ] + } + }, + "update": { + "site": { + "name": "Data Center West", + "slug": "dc-west", + "status": "active", + "region": { + "name": "North America", + "slug": "north-america" + }, + "group": { + "name": "Primary Data Centers", + "slug": "primary-dcs" + }, + "tenant": {"name": "Tenant 1"}, + "facility": "Building 7", + "time_zone": "America/Los_Angeles", + "description": "Primary West Coast Data Center Updated", + "physical_address": "123 Tech Drive, San Jose, CA 95134", + "shipping_address": "Receiving Dock 3, 123 Tech Drive, San Jose, CA 95134", + "latitude": 37.3382, + "longitude": -121.8863, + "comments": "24x7 access requires security clearance", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}], + "asns": [ + { + "asn": "555", + "rir": {"name": "RIR 1"}, + "tenant": {"name": "Tenant 1"}, + "description": "ASN 555 Description", + "comments": "ASN 555 Comments", + "tags": [{"name": "Tag 1"}] + } + ] + } + }, + "update_expect": { + "description": "Primary West Coast Data Center Updated" + } + }, + { + "name": "dcim_sitegroup_1", + "object_type": "dcim.sitegroup", + "lookup": {"name": "Global Data Centers"}, + "create_expect": { + "name": "Global Data Centers", + "parent.name": "Infrastructure", + "description": "Worldwide data center facilities" + }, + "create": { + "site_group": { + "name": "Global Data Centers", + "slug": "global-dcs", + "parent": { + "name": "Infrastructure", + "slug": "infrastructure" + }, + "description": "Worldwide data center facilities", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "site_group": { + "name": "Global Data Centers", + "slug": "global-dcs", + "parent": { + "name": "Infrastructure", + "slug": "infrastructure" + }, + "description": "Worldwide data center facilities Updated", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Worldwide data center facilities Updated" + } + }, + { + "name": "extras_tag_1", + "object_type": "extras.tag", + "lookup": {"name": "Production"}, + "create_expect": { + "name": "Production", + "slug": "production", + "color": "ff0000" + }, + "create": { + "tag": { + "name": "Production", + "slug": "production", + "color": "ff0000" + } + }, + "update": { + "tag": { + "name": "Production", + "slug": "production", + "color": "00ff00" + } + }, + "update_expect": { + "color": "00ff00" + } + }, + { + "name": "tenancy_tenant_1", + "object_type": "tenancy.tenant", + "lookup": {"name": "Acme Corporation"}, + "create_expect": { + "name": "Acme Corporation", + "slug": "acme-corp", + "description": "Global technology solutions provider" + }, + "create": { + "tenant": { + "name": "Acme Corporation", + "slug": "acme-corp", + "group": { + "name": "Enterprise Customers", + "slug": "enterprise-customers" + }, + "description": "Global technology solutions provider", + "comments": "Fortune 500 company with worldwide operations", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + + } + }, + "update": { + "tenant": { + "name": "Acme Corporation", + "slug": "acme-corp", + "group": { + "name": "Enterprise Customers", + "slug": "enterprise-customers" + }, + "description": "Global technology solutions provider Updated", + "comments": "Fortune 500 company with worldwide operations", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Global technology solutions provider Updated" + } + }, + { + "name": "tenancy_tenantgroup_1", + "object_type": "tenancy.tenantgroup", + "lookup": {"name": "Financial Services"}, + "create_expect": { + "name": "Financial Services", + "description": "Banking and financial industry customers" + }, + "create": { + "tenant_group": { + "name": "Financial Services", + "slug": "financial-services", + "parent": { + "name": "Enterprise Sectors", + "slug": "enterprise-sectors" + }, + "description": "Banking and financial industry customers", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "tenant_group": { + "name": "Financial Services", + "slug": "financial-services", + "parent": { + "name": "Enterprise Sectors", + "slug": "enterprise-sectors" + }, + "description": "Banking and financial industry customers Updated", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Banking and financial industry customers Updated" + } + }, + { + "name": "vpn_tunnel_1", + "object_type": "vpn.tunnel", + "lookup": {"name": "DC-West-to-East-Primary"}, + "create_expect": { + "name": "DC-West-to-East-Primary", + "status": "active" + }, + "create": { + "tunnel": { + "name": "DC-West-to-East-Primary", + "status": "active", + "group": { + "name": "Inter-DC Tunnels", + "slug": "inter-dc-tunnels" + }, + "encapsulation": "ipsec-tunnel", + "ipsec_profile": { + "name": "IPSEC-PROFILE-1", + "mode": "esp", + "ike_policy": { + "name": "IKE-POLICY-TUN-1", + "version": "2", + "preshared_key": "1234567890", + "comments": "Using AES-256-GCM encryption with PFS" + }, + "ipsec_policy": { + "name": "IPSEC-POLICY-1", + "pfs_group": "2" + } + }, + "tenant": {"name": "Tenant 1"}, + "tunnel_id": "1001", + "description": "Primary IPSec tunnel between West and East data centers", + "comments": "Using AES-256-GCM encryption with PFS", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "tunnel": { + "name": "DC-West-to-East-Primary", + "status": "active", + "group": { + "name": "Inter-DC Tunnels", + "slug": "inter-dc-tunnels" + }, + "encapsulation": "ipsec-tunnel", + "ipsec_profile": { + "name": "IPSEC-PROFILE-1", + "mode": "esp", + "ike_policy": { + "name": "IKE-POLICY-TUN-1", + "version": "2", + "preshared_key": "1234567890", + "comments": "Using AES-256-GCM encryption with PFS" + }, + "ipsec_policy": { + "name": "IPSEC-POLICY-1", + "pfs_group": "2" + } + }, + "tenant": {"name": "Tenant 1"}, + "tunnel_id": "1001", + "description": "Primary IPSec tunnel between West and East data centers Updated", + "comments": "Using AES-256-GCM encryption with PFS", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Primary IPSec tunnel between West and East data centers Updated" + } + }, + { + "name": "vpn_tunnel_group_1", + "object_type": "vpn.tunnelgroup", + "lookup": {"name": "Regional Backbones"}, + "create_expect": { + "name": "Regional Backbones", + "description": "High-capacity encrypted tunnels between regional data centers" + }, + "create": { + "tunnel_group": { + "name": "Regional Backbones", + "slug": "regional-backbones", + "description": "High-capacity encrypted tunnels between regional data centers", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "tunnel_group": { + "name": "Regional Backbones", + "slug": "regional-backbones", + "description": "High-capacity encrypted tunnels between regional data centers Updated", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "High-capacity encrypted tunnels between regional data centers Updated" + } + }, + { + "name": "vpn_tunneltermination_1", + "object_type": "vpn.tunneltermination", + "lookup": {"tunnel__name": "DC-West-to-East-Primary"}, + "create_expect": { + "tunnel.name": "DC-West-to-East-Primary", + "role": "hub" + }, + "create": { + "tunnel_termination": { + "tunnel": { + "name": "DC-West-to-East-Primary", + "status": "active", + "encapsulation": "ipsec-tunnel" + }, + "role": "hub", + "termination_device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "outside_ip": { + "address": "203.0.113.1/24", + "status": "active", + "dns_name": "vpn1.example.com" + }, + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "tunnel_termination": { + "tunnel": { + "name": "DC-West-to-East-Primary", + "status": "active", + "encapsulation": "ipsec-tunnel" + }, + "role": "hub", + "termination_device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "outside_ip": { + "address": "203.0.113.1/24", + "status": "active", + "dns_name": "vpn1.example.com" + }, + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}, {"name": "Tag 3"}] + } + }, + "update_expect": { + "tags.all.__by_name": ["Tag 1", "Tag 2", "Tag 3"] + } + }, + { + "name": "ipam_vlan_1", + "object_type": "ipam.vlan", + "lookup": {"vid": 807}, + "create_expect": { + "vid": 807, + "name": "Production Servers" + }, + "create": { + "vlan": { + "group": { + "name": "Production VLANs", + "slug": "production-vlans" + }, + "vid": "807", + "name": "Production Servers", + "tenant": {"name": "Tenant 1"}, + "status": "active", + "role": { + "name": "Production", + "slug": "production" + }, + "description": "Primary production server network", + "qinq_role": "cvlan", + "qinq_svlan": { + "vid": "1909", + "name": "Service Provider VLAN" + }, + "comments": "Used for customer-facing production workloads", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "vlan": { + "group": { + "name": "Production VLANs", + "slug": "production-vlans" + }, + "vid": "807", + "name": "Production Servers", + "tenant": {"name": "Tenant 1"}, + "status": "active", + "role": { + "name": "Production", + "slug": "production" + }, + "description": "Primary production server network Updated", + "qinq_role": "cvlan", + "qinq_svlan": { + "vid": "1909", + "name": "Service Provider VLAN" + }, + "comments": "Used for customer-facing production workloads", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Primary production server network Updated" + } + }, + { + "name": "ipam_vlan_group_1", + "object_type": "ipam.vlangroup", + "lookup": {"name": "Data Center Core"}, + "create_expect": { + "name": "Data Center Core", + "slug": "dc-core", + "description": "Core network VLANs for data center infrastructure" + }, + "create": { + "vlan_group": { + "name": "Data Center Core", + "slug": "dc-core", + "scope_site": { + "name": "Data Center West", + "slug": "dc-west", + "status": "active" + }, + "vid_ranges": ["101", "102", "99", "100"], + "description": "Core network VLANs for data center infrastructure", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "vlan_group": { + "name": "Data Center Core", + "slug": "dc-core", + "scope_site": { + "name": "Data Center West", + "slug": "dc-west", + "status": "active" + }, + "vid_ranges": ["101", "102", "99", "100"], + "description": "Core network VLANs for data center infrastructure Updated", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Core network VLANs for data center infrastructure Updated", + "vid_ranges": [[99, 100], [101, 102]] + } + }, + { + "name": "ipam_vlan_translation_policy_1", + "object_type": "ipam.vlantranslationpolicy", + "lookup": {"name": "Customer Edge Translation"}, + "create_expect": { + "name": "Customer Edge Translation", + "description": "VLAN translation policy for customer edge interfaces" + }, + "create": { + "vlan_translation_policy": { + "name": "Customer Edge Translation", + "description": "VLAN translation policy for customer edge interfaces" + } + }, + "update": { + "vlan_translation_policy": { + "name": "Customer Edge Translation", + "description": "VLAN translation policy for customer edge interfaces Updated" + } + }, + "update_expect": { + "description": "VLAN translation policy for customer edge interfaces Updated" + } + }, + { + "name": "ipam_vlan_translation_rule_1", + "object_type": "ipam.vlantranslationrule", + "lookup": {"policy__name": "Customer Edge Translation", "local_vid": "100"}, + "create_expect": { + "policy.name": "Customer Edge Translation", + "local_vid": 100, + "remote_vid": 1100, + "description": "Map customer VLAN 100 to provider VLAN 1100" + }, + "create": { + "vlan_translation_rule": { + "policy": { + "name": "Customer Edge Translation", + "description": "VLAN translation policy for customer edge interfaces" + }, + "local_vid": "100", + "remote_vid": "1100", + "description": "Map customer VLAN 100 to provider VLAN 1100" + } + }, + "update": { + "vlan_translation_rule": { + "policy": { + "name": "Customer Edge Translation", + "description": "VLAN translation policy for customer edge interfaces" + }, + "local_vid": "100", + "remote_vid": "1100", + "description": "Map customer VLAN 100 to provider VLAN 1100 Updated" + } + }, + "update_expect": { + "description": "Map customer VLAN 100 to provider VLAN 1100 Updated" + } + }, + { + "name": "virtualization_vminterface_1", + "object_type": "virtualization.vminterface", + "lookup": {"name": "eth0"}, + "create_expect": { + "name": "eth0", + "description": "Primary network interface" + }, + "create": { + "vm_interface": { + "virtual_machine": { + "name": "web-server-01", + "status": "active", + "role": {"name": "Web Server"}, + "site": {"name": "Site 1"} + }, + "name": "eth0", + "enabled": true, + "parent": { + "virtual_machine": { + "name": "web-server-01", + "status": "active", + "role": {"name": "Web Server"}, + "site": {"name": "Site 1"} + }, + "name": "bond0" + }, + "bridge": { + "virtual_machine": { + "name": "web-server-01", + "status": "active", + "role": {"name": "Web Server"}, + "site": {"name": "Site 1"} + }, + "name": "br0" + }, + "mtu": "9000", + "primary_mac_address": { + "mac_address": "00:1A:2B:3C:4D:5E" + }, + "description": "Primary network interface", + "mode": "q-in-q", + "untagged_vlan": { + "vid": "1101", + "name": "Production Servers" + }, + "qinq_svlan": { + "vid": "1000", + "name": "Service Provider VLAN" + }, + "vlan_translation_policy": { + "name": "Customer Edge Translation" + }, + "vrf": { + "name": "PROD-VRF", + "rd": "65000:1" + }, + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "vm_interface": { + "virtual_machine": { + "name": "web-server-01", + "status": "active", + "role": {"name": "Web Server"}, + "site": {"name": "Site 1"} + }, + "name": "eth0", + "enabled": true, + "parent": { + "virtual_machine": { + "name": "web-server-01", + "status": "active", + "role": {"name": "Web Server"}, + "site": {"name": "Site 1"} + }, + "name": "bond0" + }, + "bridge": { + "virtual_machine": { + "name": "web-server-01", + "status": "active", + "role": {"name": "Web Server"}, + "site": {"name": "Site 1"} + }, + "name": "br0" + }, + "mtu": "9000", + "primary_mac_address": { + "mac_address": "00:1A:2B:3C:4D:5E" + }, + "description": "Primary network interface Updated", + "mode": "q-in-q", + "untagged_vlan": { + "vid": "1101", + "name": "Production Servers" + }, + "qinq_svlan": { + "vid": "1000", + "name": "Service Provider VLAN" + }, + "vlan_translation_policy": { + "name": "Customer Edge Translation" + }, + "vrf": { + "name": "PROD-VRF", + "rd": "65000:1" + }, + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Primary network interface Updated" + } + }, + { + "name": "ipam_vrf_1", + "object_type": "ipam.vrf", + "lookup": {"name": "Customer-A-VRF"}, + "create_expect": { + "name": "Customer-A-VRF", + "rd": "65000:100", + "tenant.name": "Tenant 1", + "enforce_unique": true, + "description": "Isolated routing domain for Customer A", + "comments": "Used for customer's private network services" + }, + "create": { + "vrf": { + "name": "Customer-A-VRF", + "rd": "65000:100", + "tenant": {"name": "Tenant 1"}, + "enforce_unique": true, + "description": "Isolated routing domain for Customer A", + "comments": "Used for customer's private network services", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}], + "import_targets": [ + { + "name": "65000:100" + }, + { + "name": "65000:101" + } + ], + "export_targets": [ + { + "name": "65000:103" + } + ] + } + }, + "update": { + "vrf": { + "name": "Customer-A-VRF", + "rd": "65000:100", + "tenant": {"name": "Tenant 1"}, + "enforce_unique": true, + "description": "Isolated routing domain for Customer A Updated", + "comments": "Used for customer's private network services", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}], + "import_targets": [ + { + "name": "65000:100" + }, + { + "name": "65000:101" + } + ], + "export_targets": [ + { + "name": "65000:103" + } + ] + } + }, + "update_expect": { + "description": "Isolated routing domain for Customer A Updated" + } + }, + { + "name": "dcim_virtualchassis_1", + "object_type": "dcim.virtualchassis", + "lookup": {"name": "Stack-DC1-Core"}, + "create_expect": { + "name": "Stack-DC1-Core", + "domain": "dc1-core.example.com" + }, + "create": { + "virtual_chassis": { + "name": "Stack-DC1-Core", + "domain": "dc1-core.example.com", + "master": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "description": "Core switch stack in DC1", + "comments": "Primary switching infrastructure for data center 1", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "virtual_chassis": { + "name": "Stack-DC1-Core", + "domain": "dc1-core.example.com", + "master": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "description": "Core switch stack in DC1 Updated", + "comments": "Primary switching infrastructure for data center 1", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Core switch stack in DC1 Updated" + } + }, + { + "name": "circuits_virtualcircuit_1", + "object_type": "circuits.virtualcircuit", + "lookup": {"cid": "VC-001-LAX-NYC"}, + "create_expect": { + "cid": "VC-001-LAX-NYC" + }, + "create": { + "virtual_circuit": { + "cid": "VC-001-LAX-NYC", + "provider_network": { + "provider": {"name": "Level 3 Communications"}, + "name": "Global MPLS Network", + "service_id": "L3-MPLS-001" + }, + "provider_account": { + "provider": {"name": "Level 3 Communications"}, + "name": "East Coast Account", + "account": "L3-12345" + }, + "type": { + "name": "MPLS L3VPN", + "slug": "mpls-l3vpn" + }, + "status": "active", + "tenant": {"name": "Tenant 1"}, + "description": "LAX to NYC MPLS circuit", + "comments": "Primary east-west connectivity", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "virtual_circuit": { + "cid": "VC-001-LAX-NYC", + "provider_network": { + "provider": {"name": "Level 3 Communications"}, + "name": "Global MPLS Network", + "service_id": "L3-MPLS-001" + }, + "provider_account": { + "provider": {"name": "Level 3 Communications"}, + "name": "East Coast Account", + "account": "L3-12345" + }, + "type": { + "name": "MPLS L3VPN", + "slug": "mpls-l3vpn" + }, + "status": "active", + "tenant": {"name": "Tenant 1"}, + "description": "LAX to NYC MPLS circuit Updated", + "comments": "Primary east-west connectivity", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "LAX to NYC MPLS circuit Updated" + } + }, + { + "name": "circuits_virtualcircuittermination_1", + "object_type": "circuits.virtualcircuittermination", + "lookup": {"virtual_circuit__cid": "VC-001-LAX-NYC"}, + "create_expect": { + "virtual_circuit.cid": "VC-001-LAX-NYC", + "role": "hub", + "interface.device.name": "Device 1" + }, + "create": { + "virtual_circuit_termination": { + "virtual_circuit": { + "cid": "VC-001-LAX-NYC", + "provider_network": { + "provider": {"name": "Level 3 Communications"}, + "name": "Global MPLS Network" + }, + "type": { + "name": "MPLS L3VPN", + "slug": "mpls-l3vpn" + } + }, + "role": "hub", + "interface": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "MegabitEthernet1/0/1", + "type": "virtual", + "enabled": true + }, + "description": "LAX hub termination for east-west MPLS circuit", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "virtual_circuit_termination": { + "virtual_circuit": { + "cid": "VC-001-LAX-NYC", + "provider_network": { + "provider": {"name": "Level 3 Communications"}, + "name": "Global MPLS Network" + }, + "type": { + "name": "MPLS L3VPN", + "slug": "mpls-l3vpn" + } + }, + "role": "hub", + "interface": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "MegabitEthernet1/0/1", + "type": "virtual", + "enabled": true + }, + "description": "LAX hub termination for east-west MPLS circuit Updated", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "LAX hub termination for east-west MPLS circuit Updated" + } + }, + { + "name": "circuits_virtualcircuittype_1", + "object_type": "circuits.virtualcircuittype", + "lookup": {"name": "EVPN-VXLAN"}, + "create_expect": { + "name": "EVPN-VXLAN", + "description": "Data center interconnect using EVPN-VXLAN overlay" + }, + "create": { + "virtual_circuit_type": { + "name": "EVPN-VXLAN", + "slug": "evpn-vxlan", + "color": "0000ff", + "description": "Data center interconnect using EVPN-VXLAN overlay", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "virtual_circuit_type": { + "name": "EVPN-VXLAN", + "slug": "evpn-vxlan", + "color": "0000ff", + "description": "Data center interconnect using EVPN-VXLAN overlay Updated", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Data center interconnect using EVPN-VXLAN overlay Updated" + } + }, + { + "name": "dcim_virtualdevicecontext_1", + "object_type": "dcim.virtualdevicecontext", + "lookup": {"name": "VDC-Production"}, + "create_expect": { + "name": "VDC-Production", + "description": "Production virtual device context", + "comments": "Isolated network context for production services", + "identifier": 1, + "device.name": "Device 1", + "primary_ip4.address": "192.168.1.1/32", + "primary_ip6.address": "2001:db8::1/128" + }, + "create": { + "virtual_device_context": { + "name": "VDC-Production", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "identifier": "1", + "tenant": {"name": "Tenant 1"}, + "primary_ip4": { + "address": "192.168.1.1", + "assigned_object_interface": { + "type": "1000base-t", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "eth0" + } + }, + "primary_ip6": { + "address": "2001:db8::1", + "assigned_object_interface": { + "type": "1000base-t", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "eth0" + } + }, + "status": "active", + "description": "Production virtual device context", + "comments": "Isolated network context for production services", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + + }, + "update": { + "virtual_device_context": { + "name": "VDC-Production", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "identifier": "1", + "tenant": {"name": "Tenant 1"}, + "primary_ip4": { + "address": "192.168.1.1", + "assigned_object_interface": { + "type": "1000base-t", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "eth0" + } + }, + "primary_ip6": { + "address": "2001:db8::1", + "assigned_object_interface": { + "type": "1000base-t", + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "eth0" + } + }, + "status": "active", + "description": "Production virtual device context Updated", + "comments": "Isolated network context for production services", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Production virtual device context Updated" + } + }, + { + "name": "virtualization_virtualdisk_1", + "object_type": "virtualization.virtualdisk", + "lookup": {"name": "root-volume"}, + "create_expect": { + "name": "root-volume", + "description": "Primary system disk" + }, + "create": { + "virtual_disk": { + "virtual_machine": { + "name": "web-server-01", + "status": "active", + "role": {"name": "Web Server"}, + "site": {"name": "Site 1"} + }, + "name": "root-volume", + "description": "Primary system disk", + "size": "182400", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "virtual_disk": { + "virtual_machine": { + "name": "web-server-01", + "status": "active", + "role": {"name": "Web Server"}, + "site": {"name": "Site 1"} + }, + "name": "root-volume", + "description": "Primary system disk Updated", + "size": "182400", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Primary system disk Updated" + } + }, + { + "name": "virtualization_virtualmachine_1", + "object_type": "virtualization.virtualmachine", + "lookup": {"name": "app-server-01"}, + "create_expect": { + "name": "app-server-01", + "description": "Primary application server instance", + "comments": "Hosts critical business applications" + }, + "create": { + "virtual_machine": { + "name": "app-server-01", + "status": "active", + "site": {"name": "Site 1"}, + "cluster": { + "name": "Cluster 1", + "type": {"name": "Cluster Type 1"}, + "scope_site": {"name": "Site 1"} + }, + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"}, + "cluster": { + "name": "Cluster 1", + "type": {"name": "Cluster Type 1"}, + "scope_site": {"name": "Site 1"} + } + }, + "serial": "VM-2023-001", + "role": {"name": "Application Server"}, + "tenant": {"name": "Tenant 1"}, + "platform": {"name": "Ubuntu 22.04"}, + "primary_ip4": { + "address": "192.168.2.99", + "assigned_object_vm_interface": { + "virtual_machine": { + "name": "app-server-01", + "cluster": { + "name": "Cluster 1", + "type": {"name": "Cluster Type 1"}, + "scope_site": {"name": "Site 1"} + }, + "tenant": {"name": "Tenant 1"} + }, + "name": "eth0", + "enabled": true, + "mtu": "1500" + } + }, + "primary_ip6": { + "address": "2001:db8::99", + "assigned_object_vm_interface": { + "virtual_machine": { + "name": "app-server-01", + "cluster": { + "name": "Cluster 1", + "type": {"name": "Cluster Type 1"}, + "scope_site": {"name": "Site 1"} + }, + "tenant": {"name": "Tenant 1"} + }, + "name": "eth0", + "enabled": true, + "mtu": "1500" + } + }, + "vcpus": 4.0, + "memory": "214748364", + "disk": "147483647", + "description": "Primary application server instance", + "comments": "Hosts critical business applications", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "virtual_machine": { + "name": "app-server-01", + "status": "active", + "site": {"name": "Site 1"}, + "cluster": { + "name": "Cluster 1", + "type": {"name": "Cluster Type 1"}, + "scope_site": {"name": "Site 1"} + }, + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"}, + "cluster": { + "name": "Cluster 1", + "type": {"name": "Cluster Type 1"}, + "scope_site": {"name": "Site 1"} + } + }, + "serial": "VM-2023-001", + "role": {"name": "Application Server"}, + "tenant": {"name": "Tenant 1"}, + "platform": {"name": "Ubuntu 22.04"}, + "primary_ip4": { + "address": "192.168.2.99", + "assigned_object_vm_interface": { + "virtual_machine": { + "name": "app-server-01", + "cluster": { + "name": "Cluster 1", + "type": {"name": "Cluster Type 1"}, + "scope_site": {"name": "Site 1"} + }, + "tenant": {"name": "Tenant 1"} + }, + "name": "eth0", + "enabled": true, + "mtu": "1500" + } + }, + "primary_ip6": { + "address": "2001:db8::99", + "assigned_object_vm_interface": { + "virtual_machine": { + "name": "app-server-01", + "cluster": { + "name": "Cluster 1", + "type": {"name": "Cluster Type 1"}, + "scope_site": {"name": "Site 1"} + }, + "tenant": {"name": "Tenant 1"} + }, + "name": "eth0", + "enabled": true, + "mtu": "1500" + } + }, + "vcpus": 4.0, + "memory": "214748364", + "disk": "147483647", + "description": "Primary application server instance Updated", + "comments": "Hosts critical business applications", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Primary application server instance Updated" + } + }, + { + "name": "wireless_wirelesslan_1", + "object_type": "wireless.wirelesslan", + "lookup": {"ssid": "Corp-Secure"}, + "create_expect": { + "ssid": "Corp-Secure", + "group.name": "Corporate Networks", + "description": "Corporate secure wireless network" + }, + "create": { + "wireless_lan": { + "ssid": "Corp-Secure", + "description": "Corporate secure wireless network", + "group": { + "name": "Corporate Networks", + "slug": "corporate-networks" + }, + "status": "active", + "vlan": { + "vid": 100, + "name": "Production Servers" + }, + "scope_site": {"name": "Site 1"}, + "tenant": {"name": "Tenant 1"}, + "auth_type": "wpa-enterprise", + "auth_cipher": "aes", + "auth_psk": "SecureWiFiKey123!", + "comments": "Primary corporate wireless network with 802.1X authentication", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "wireless_lan": { + "ssid": "Corp-Secure", + "description": "Corporate secure wireless network Updated", + "group": { + "name": "Corporate Networks", + "slug": "corporate-networks" + }, + "status": "active", + "vlan": { + "vid": 100, + "name": "Production Servers" + }, + "scope_site": {"name": "Site 1"}, + "tenant": {"name": "Tenant 1"}, + "auth_type": "wpa-enterprise", + "auth_cipher": "aes", + "auth_psk": "SecureWiFiKey123!", + "comments": "Primary corporate wireless network with 802.1X authentication", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Corporate secure wireless network Updated" + } + }, + { + "name": "wireless_wirelesslangroup_1", + "object_type": "wireless.wirelesslangroup", + "lookup": {"name": "Corporate Networks"}, + "create_expect": { + "name": "Corporate Networks", + "parent.name": "All Networks", + "description": "Enterprise corporate wireless networks" + }, + "create": { + "wireless_lan_group": { + "name": "Corporate Networks", + "slug": "corporate-networks", + "parent": { + "name": "All Networks", + "slug": "all-networks" + }, + "description": "Enterprise corporate wireless networks", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "wireless_lan_group": { + "name": "Corporate Networks", + "slug": "corporate-networks", + "parent": { + "name": "All Networks", + "slug": "all-networks" + }, + "description": "Enterprise corporate wireless networks Updated", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Enterprise corporate wireless networks Updated" + } + }, + { + "name": "wireless_wirelesslink_1", + "object_type": "wireless.wirelesslink", + "lookup": {"ssid": "P2P-Link-1"}, + "create_expect": { + "interface_a.device.name": "Device 1", + "interface_b.device.name": "Device 2", + "description": "Point-to-point wireless backhaul link" + }, + "create": { + "wireless_link": { + "interface_a": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "Radio0/1", + "type": "ieee802.11ac", + "enabled": true + }, + "interface_b": { + "device": { + "name": "Device 2", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "Radio0/1", + "type": "ieee802.11ac", + "enabled": true + }, + "ssid": "P2P-Link-1", + "status": "connected", + "tenant": {"name": "Tenant 1"}, + "auth_type": "wpa-personal", + "auth_cipher": "aes", + "auth_psk": "P2PLinkKey123!", + "distance": 1.5, + "distance_unit": "km", + "description": "Point-to-point wireless backhaul link", + "comments": "Building A to Building B wireless bridge", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update": { + "wireless_link": { + "interface_a": { + "device": { + "name": "Device 1", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "Radio0/1", + "type": "ieee802.11ac", + "enabled": true + }, + "interface_b": { + "device": { + "name": "Device 2", + "role": {"name": "Device Role 1"}, + "device_type": { + "manufacturer": {"name": "Cisco"}, + "model": "C2960S" + }, + "site": {"name": "Site 1"} + }, + "name": "Radio0/1", + "type": "ieee802.11ac", + "enabled": true + }, + "ssid": "P2P-Link-1", + "status": "connected", + "tenant": {"name": "Tenant 1"}, + "auth_type": "wpa-personal", + "auth_cipher": "aes", + "auth_psk": "P2PLinkKey123!", + "distance": 1.5, + "distance_unit": "km", + "description": "Point-to-point wireless backhaul link Updated", + "comments": "Building A to Building B wireless bridge", + "tags": [{"name": "Tag 1"}, {"name": "Tag 2"}] + } + }, + "update_expect": { + "description": "Point-to-point wireless backhaul link Updated" + } + } +] \ No newline at end of file diff --git a/netbox_diode_plugin/tests/v4.2.3/tests/test_version.py b/netbox_diode_plugin/tests/v4.2.3/tests/test_version.py new file mode 100644 index 0000000..4d65341 --- /dev/null +++ b/netbox_diode_plugin/tests/v4.2.3/tests/test_version.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python +# Copyright 2025 NetBox Labs, Inc. +"""Diode NetBox Plugin - Tests.""" + +from django.test import TestCase + +from netbox_diode_plugin.version import version_display, version_semver + + +class VersionTestCase(TestCase): + """Test case for the version module.""" + + def test_version(self): + """Check the injected semver.""" + assert version_semver() == "0.0.0" + + def test_version_display(self): + """Check the injected display.""" + assert version_display() == "v0.0.0-dev-unknown" diff --git a/netbox_diode_plugin/tests/v4.2.3/tests/test_views.py b/netbox_diode_plugin/tests/v4.2.3/tests/test_views.py new file mode 100644 index 0000000..80620f6 --- /dev/null +++ b/netbox_diode_plugin/tests/v4.2.3/tests/test_views.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python +# Copyright 2025 NetBox Labs, Inc. +"""Diode NetBox Plugin - Tests.""" +from unittest import mock + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser +from django.contrib.messages.middleware import MessageMiddleware +from django.contrib.messages.storage.fallback import FallbackStorage +from django.contrib.sessions.middleware import SessionMiddleware +from django.test import RequestFactory +from django.test import TestCase as _TestCase +from django.urls import reverse +from rest_framework import status +from users.models import ObjectPermission +from utilities.permissions import resolve_permission_type + +from netbox_diode_plugin.models import Setting +from netbox_diode_plugin.views import SettingsEditView, SettingsView + +User = get_user_model() + + +class TestCase(_TestCase): + """Base test case class for NetBox Diode plugin tests.""" + + def add_permissions(self, user, *names): + """Assign a set of permissions to the test user. Accepts permission names in the form ._.""" + for name in names: + object_type, action = resolve_permission_type(name) + obj_perm = ObjectPermission(name=name, actions=[action]) + obj_perm.save() + obj_perm.users.add(user) + obj_perm.object_types.add(object_type) + + +class SettingsViewTestCase(TestCase): + """Test case for the SettingsView.""" + + def setUp(self): + """Setup the test case.""" + self.path = reverse("plugins:netbox_diode_plugin:settings") + self.request = RequestFactory().get(self.path) + self.view = SettingsView() + self.view.setup(self.request) + + def test_returns_200_for_authenticated(self): + """Test that the view returns 200 for an authenticated user.""" + self.request.user = User.objects.create_user("foo", password="pass") + self.add_permissions(self.request.user, "netbox_diode_plugin.view_setting") + + response = self.view.get(self.request) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_redirects_to_login_page_for_unauthenticated_user(self): + """Test that the view returns 200 for an authenticated user.""" + self.request.user = AnonymousUser() + self.view.setup(self.request) + + response = SettingsView.as_view()(self.request) + + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + self.assertEqual(response.url, f"/netbox/login/?next={self.path}") + + def test_settings_created_if_not_found(self): + """Test that the settings are created with placeholder data if not found.""" + self.request.user = User.objects.create_user("foo", password="pass") + self.add_permissions(self.request.user, "netbox_diode_plugin.view_setting") + + with mock.patch("netbox_diode_plugin.models.Setting.objects.get") as mock_get: + mock_get.side_effect = Setting.DoesNotExist + + response = self.view.get(self.request) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("grpc://localhost:8080/diode", str(response.content)) + + +class SettingsEditViewTestCase(TestCase): + """Test case for the SettingsEditView.""" + + def setUp(self): + """Setup the test case.""" + self.path = reverse("plugins:netbox_diode_plugin:settings_edit") + self.request_factory = RequestFactory() + self.view = SettingsEditView() + + def test_returns_200_for_authenticated(self): + """Test that the view returns 200 for an authenticated user.""" + request = self.request_factory.get(self.path) + request.user = User.objects.create_user("foo", password="pass", is_staff=True) + self.add_permissions(request.user, "netbox_diode_plugin.view_setting", "netbox_diode_plugin.change_setting") + request.htmx = None + self.view.setup(request) + + response = self.view.get(request) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_redirects_to_login_page_for_unauthenticated_user(self): + """Test that the view redirects an authenticated user to login page.""" + request = self.request_factory.get(self.path) + request.user = AnonymousUser() + self.view.setup(request) + + response = self.view.get(request) + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + self.assertEqual(response.url, f"/netbox/login/?next={self.path}") + + def test_settings_updated(self): + """Test that the settings are updated.""" + user = User.objects.create_user("foo", password="pass", is_staff=True) + self.add_permissions(user, "netbox_diode_plugin.view_setting", "netbox_diode_plugin.change_setting") + + request = self.request_factory.get(self.path) + request.user = user + request.htmx = None + self.view.setup(request) + + response = self.view.get(request) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("grpc://localhost:8080/diode", str(response.content)) + + request = self.request_factory.post(self.path) + request.user = user + request.htmx = None + request.POST = {"diode_target": "grpc://localhost:8090/diode"} + + middleware = SessionMiddleware(get_response=lambda request: None) + middleware.process_request(request) + request.session.save() + + middleware = MessageMiddleware(get_response=lambda request: None) + middleware.process_request(request) + request.session.save() + + response = self.view.post(request) + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + self.assertEqual(response.url, reverse("plugins:netbox_diode_plugin:settings")) + + request = self.request_factory.get(self.path) + request.user = user + request.htmx = None + self.view.setup(request) + + response = self.view.get(request) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("grpc://localhost:8090/diode", str(response.content)) + + def test_settings_update_post_redirects_to_login_page_for_unauthenticated_user( + self, + ): + """Test that the view redirects an authenticated user to login page.""" + request = self.request_factory.post(self.path) + request.user = AnonymousUser() + request.htmx = None + request.POST = {"diode_target": "grpc://localhost:8090/diode"} + + response = self.view.post(request) + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + self.assertEqual(response.url, f"/netbox/login/?next={self.path}") + + def test_settings_update_disallowed_on_get_method(self): + """Test that the accessing settings edit is not allowed with diode target override.""" + with mock.patch( + "netbox_diode_plugin.views.get_plugin_config" + ) as mock_get_plugin_config: + mock_get_plugin_config.return_value = "grpc://localhost:8080/diode" + + user = User.objects.create_user("foo", password="pass", is_staff=True) + self.add_permissions( + user, + "netbox_diode_plugin.view_setting", + "netbox_diode_plugin.add_setting", + "netbox_diode_plugin.change_setting", + ) + + request = self.request_factory.post(self.path) + request.user = user + request.htmx = None + + middleware = SessionMiddleware(get_response=lambda request: None) + middleware.process_request(request) + request.session.save() + + middleware = MessageMiddleware(get_response=lambda request: None) + middleware.process_request(request) + request.session.save() + + setattr(request, "session", "session") + messages = FallbackStorage(request) + request._messages = messages + + self.view.setup(request) + response = self.view.get(request) + + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + self.assertEqual( + response.url, reverse("plugins:netbox_diode_plugin:settings") + ) + self.assertEqual(len(request._messages._queued_messages), 1) + self.assertEqual( + str(request._messages._queued_messages[0]), + "The Diode target is not allowed to be modified.", + ) + + def test_settings_update_disallowed_on_post_method(self): + """Test that the updating settings is not allowed with diode target override.""" + with mock.patch( + "netbox_diode_plugin.views.get_plugin_config" + ) as mock_get_plugin_config: + mock_get_plugin_config.return_value = "grpc://localhost:8080/diode" + + user = User.objects.create_user("foo", password="pass", is_staff=True) + self.add_permissions( + user, + "netbox_diode_plugin.view_setting", + "netbox_diode_plugin.add_setting", + "netbox_diode_plugin.change_setting", + ) + + request = self.request_factory.post(self.path) + request.user = user + request.htmx = None + request.POST = {"diode_target": "grpc://localhost:8090/diode"} + + middleware = SessionMiddleware(get_response=lambda request: None) + middleware.process_request(request) + request.session.save() + + middleware = MessageMiddleware(get_response=lambda request: None) + middleware.process_request(request) + request.session.save() + + setattr(request, "session", "session") + messages = FallbackStorage(request) + request._messages = messages + + self.view.setup(request) + response = self.view.post(request) + + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + self.assertEqual( + response.url, reverse("plugins:netbox_diode_plugin:settings") + ) + self.assertEqual(len(request._messages._queued_messages), 1) + self.assertEqual( + str(request._messages._queued_messages[0]), + "The Diode target is not allowed to be modified.", + ) From 73dd1ff768fd7d0b50b93b898ea01bcaf6dcea2d Mon Sep 17 00:00:00 2001 From: Michal Fiedorowicz Date: Thu, 31 Jul 2025 14:20:46 +0200 Subject: [PATCH 5/7] chore: add jajeffries and MicahParks as code owners (#123) Signed-off-by: Michal Fiedorowicz --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ab07f99..9fb01c3 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @leoparente @ltucker @mfiedorowicz +* @jajeffries @leoparente @ltucker @mfiedorowicz @MicahParks From 495d25e67bc33cac34a69f46237a7e0377b4625d Mon Sep 17 00:00:00 2001 From: NightlyNews <37782700+NightlyNews@users.noreply.github.com> Date: Fri, 1 Aug 2025 05:53:58 -0400 Subject: [PATCH 6/7] fix: typo in ObjectMatchCriteria docstring (#121) --- netbox_diode_plugin/api/matcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox_diode_plugin/api/matcher.py b/netbox_diode_plugin/api/matcher.py index 6e7c6b9..2e9cc97 100644 --- a/netbox_diode_plugin/api/matcher.py +++ b/netbox_diode_plugin/api/matcher.py @@ -263,7 +263,7 @@ class ObjectMatchCriteria: the model fields and any references to another object specify a specific id in the appropriate field name. eg device_id=123 etc and for any generic references, - both the type and idshould be specified, eg: + both the type and id should be specified, eg: scope_type="dcim.site" and scope_id=123 """ From 8bbda8b667caf0b78646946b78d7263517a13ac0 Mon Sep 17 00:00:00 2001 From: Micah Parks <66095735+MicahParks@users.noreply.github.com> Date: Tue, 12 Aug 2025 09:04:10 -0400 Subject: [PATCH 7/7] feat: diode target schemes and ports (#126) * Copy diode target parsing from SDK * Fix error message * Add diode target parsing tests * Ruff fix --- netbox_diode_plugin/plugin_config.py | 12 +++-- .../tests/test_plugin_config.py | 46 ++++++++++++++++++- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/netbox_diode_plugin/plugin_config.py b/netbox_diode_plugin/plugin_config.py index eb1cf56..e115e86 100644 --- a/netbox_diode_plugin/plugin_config.py +++ b/netbox_diode_plugin/plugin_config.py @@ -22,13 +22,19 @@ def _parse_diode_target(target: str) -> tuple[str, str, bool]: """Parse the target into authority, path and tls_verify.""" parsed_target = urlparse(target) - if parsed_target.scheme not in ["grpc", "grpcs"]: - raise ValueError("target should start with grpc:// or grpcs://") + if parsed_target.scheme not in ["grpc", "grpcs", "http", "https"]: + raise ValueError("target should start with grpc://, grpcs://, http:// or https://") - tls_verify = parsed_target.scheme == "grpcs" + tls_verify = parsed_target.scheme in ["grpcs", "https"] authority = parsed_target.netloc + if ":" not in authority: + if parsed_target.scheme in ["grpc", "http"]: + authority += ":80" + elif parsed_target.scheme in ["grpcs", "https"]: + authority += ":443" + return authority, parsed_target.path, tls_verify diff --git a/netbox_diode_plugin/tests/test_plugin_config.py b/netbox_diode_plugin/tests/test_plugin_config.py index ade00c7..a5dedac 100644 --- a/netbox_diode_plugin/tests/test_plugin_config.py +++ b/netbox_diode_plugin/tests/test_plugin_config.py @@ -2,10 +2,11 @@ # Copyright 2025 NetBox Labs, Inc. """Diode NetBox Plugin - Tests.""" +import pytest from django.contrib.auth import get_user_model from django.test import TestCase -from netbox_diode_plugin.plugin_config import get_diode_auth_introspect_url, get_diode_user +from netbox_diode_plugin.plugin_config import _parse_diode_target, get_diode_auth_introspect_url, get_diode_user User = get_user_model() @@ -24,3 +25,46 @@ def test_get_diode_user(self): expected_diode_user = User.objects.get(username="diode") self.assertEqual(diode_user, expected_diode_user) + def test__parse_diode_target_handles_ftp_prefix(self): + """Check that _parse_diode_target raises an error when the target contains ftp://.""" + with pytest.raises(ValueError): + _parse_diode_target("ftp://localhost:8081") + + def test__parse_diode_target_parses_authority_correctly(self): + """Check that _parse_diode_target parses the authority correctly.""" + authority, path, tls_verify = _parse_diode_target("grpc://localhost:8081") + assert authority == "localhost:8081" + assert path == "" + assert tls_verify is False + + def test__parse_diode_target_adds_default_port_if_missing(self): + """Check that _parse_diode_target adds the default port if missing.""" + authority, _, _ = _parse_diode_target("grpc://localhost") + assert authority == "localhost:80" + authority, _, _ = _parse_diode_target("http://localhost") + assert authority == "localhost:80" + authority, _, _ = _parse_diode_target("grpcs://localhost") + assert authority == "localhost:443" + authority, _, _ = _parse_diode_target("https://localhost") + assert authority == "localhost:443" + + def test__parse_diode_target_parses_path_correctly(self): + """Check that _parse_diode_target parses the path correctly.""" + _, path, _ = _parse_diode_target("grpc://localhost:8081/my/path") + assert path == "/my/path" + + def test__parse_diode_target_handles_no_path(self): + """Check that _parse_diode_target handles no path.""" + _, path, _ = _parse_diode_target("grpc://localhost:8081") + assert path == "" + + def test__parse_diode_target_parses_tls_verify_correctly(self): + """Check that _parse_diode_target parses tls_verify correctly.""" + _, _, tls_verify = _parse_diode_target("grpc://localhost:8081") + assert tls_verify is False + _, _, tls_verify = _parse_diode_target("http://localhost:8081") + assert tls_verify is False + _, _, tls_verify = _parse_diode_target("grpcs://localhost:8081") + assert tls_verify is True + _, _, tls_verify = _parse_diode_target("https://localhost:8081") + assert tls_verify is True