├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yaml │ └── bug_report.yaml ├── dependabot.yml └── workflows │ ├── pr-test.yml │ ├── tests.yml │ ├── release.yml │ ├── codeql-analysis.yml │ ├── feature-test.yml │ └── linter.yml ├── src └── netbox_initializers │ ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── load_initializer_data.py │ │ └── copy_initializers_examples.py │ ├── version.py │ ├── initializers │ ├── yaml │ │ ├── cluster_types.yml │ │ ├── prefix_vlan_roles.yml │ │ ├── route_targets.yml │ │ ├── cluster_groups.yml │ │ ├── tenants.yml │ │ ├── tenant_groups.yml │ │ ├── circuit_types.yml │ │ ├── contact_roles.yml │ │ ├── providers.yml │ │ ├── asns.yml │ │ ├── power_panels.yml │ │ ├── clusters.yml │ │ ├── groups.yml │ │ ├── manufacturers.yml │ │ ├── circuits.yml │ │ ├── site_groups.yml │ │ ├── aggregates.yml │ │ ├── config_templates.yml │ │ ├── vrfs.yml │ │ ├── rirs.yml │ │ ├── service_templates.yml │ │ ├── webhooks.yml │ │ ├── regions.yml │ │ ├── rack_roles.yml │ │ ├── locations.yml │ │ ├── contact_groups.yml │ │ ├── platforms.yml │ │ ├── device_roles.yml │ │ ├── macs.yml │ │ ├── tags.yml │ │ ├── power_feeds.yml │ │ ├── vlans.yml │ │ ├── services.yml │ │ ├── custom_links.yml │ │ ├── users.yml │ │ ├── vlan_groups.yml │ │ ├── racks.yml │ │ ├── contacts.yml │ │ ├── sites.yml │ │ ├── config_contexts.yml │ │ ├── virtualization_interfaces.yml │ │ ├── virtual_machines.yml │ │ ├── interfaces.yml │ │ ├── prefixes.yml │ │ ├── ip_addresses.yml │ │ ├── object_permissions.yml │ │ ├── devices.yml │ │ ├── rack_types.yml │ │ ├── device_types.yml │ │ ├── cables.yml │ │ └── custom_fields.yml │ ├── utils.py │ ├── macs.py │ ├── rirs.py │ ├── prefix_vlan_roles.py │ ├── manufacturers.py │ ├── tenant_groups.py │ ├── cluster_types.py │ ├── cluster_groups.py │ ├── groups.py │ ├── service_templates.py │ ├── users.py │ ├── circuit_types.py │ ├── contact_roles.py │ ├── rack_types.py │ ├── regions.py │ ├── rack_roles.py │ ├── webhooks.py │ ├── locations.py │ ├── device_roles.py │ ├── site_groups.py │ ├── tenants.py │ ├── platforms.py │ ├── vrfs.py │ ├── contact_groups.py │ ├── route_targets.py │ ├── asns.py │ ├── config_templates.py │ ├── providers.py │ ├── tags.py │ ├── vlans.py │ ├── custom_links.py │ ├── services.py │ ├── circuits.py │ ├── power_feeds.py │ ├── contacts.py │ ├── power_panels.py │ ├── racks.py │ ├── clusters.py │ ├── aggregates.py │ ├── prefixes.py │ ├── virtualization_interfaces.py │ ├── virtual_machines.py │ ├── sites.py │ ├── vlan_groups.py │ ├── config_contexts.py │ ├── devices.py │ ├── primary_ips.py │ ├── __init__.py │ ├── object_permissions.py │ ├── ip_addresses.py │ ├── interfaces.py │ ├── base.py │ ├── device_types.py │ ├── custom_fields.py │ └── cables.py │ └── __init__.py ├── test ├── env │ ├── redis.env │ ├── redis-cache.env │ ├── postgres.env │ └── netbox.env ├── config │ └── plugins.py ├── Dockerfile ├── gh-functions.sh ├── docker-compose.yml └── test.sh ├── .yamllint.yaml ├── .flake8 ├── .editorconfig ├── docs └── dev │ ├── Branches.md │ └── Release.md ├── pyproject.toml ├── README.md ├── .gitignore ├── LICENSE └── uv.lock /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: 2 | - tobiasge 3 | -------------------------------------------------------------------------------- /src/netbox_initializers/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/netbox_initializers/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/netbox_initializers/version.py: -------------------------------------------------------------------------------- 1 | VERSION = "4.4.0" 2 | -------------------------------------------------------------------------------- /test/env/redis.env: -------------------------------------------------------------------------------- 1 | REDIS_PASSWORD=aC4eic9if9de4eHi@kah 2 | -------------------------------------------------------------------------------- /test/config/plugins.py: -------------------------------------------------------------------------------- 1 | PLUGINS = ["netbox_initializers"] 2 | -------------------------------------------------------------------------------- /test/env/redis-cache.env: -------------------------------------------------------------------------------- 1 | REDIS_PASSWORD=AquaeTh;ae7Piev0quah 2 | -------------------------------------------------------------------------------- /.yamllint.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | rules: 4 | line-length: 5 | max: 150 6 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/cluster_types.yml: -------------------------------------------------------------------------------- 1 | # - name: Hyper-V 2 | # slug: hyper-v 3 | -------------------------------------------------------------------------------- /test/env/postgres.env: -------------------------------------------------------------------------------- 1 | POSTGRES_DB=netbox 2 | POSTGRES_PASSWORD=igohhi0iewoo+chuy4G 3 | POSTGRES_USER=netbox 4 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/prefix_vlan_roles.yml: -------------------------------------------------------------------------------- 1 | # - name: Main Management 2 | # slug: main-management 3 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/route_targets.yml: -------------------------------------------------------------------------------- 1 | # - name: 65000:1001 2 | # tenant: tenant1 3 | # - name: 65000:1002 4 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | per-file-ignores = 4 | src/netbox_initializers/initializers/__init__.py:F401,E402 5 | 6 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/cluster_groups.yml: -------------------------------------------------------------------------------- 1 | # - name: Group 1 2 | # slug: group-1 3 | # - name: Group 2 4 | # slug: group-2 5 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/tenants.yml: -------------------------------------------------------------------------------- 1 | # - name: tenant1 2 | # slug: tenant1 3 | # - name: tenant2 4 | # slug: tenant2 5 | # group: Tenant Group 2 6 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/tenant_groups.yml: -------------------------------------------------------------------------------- 1 | # - name: Tenant Group 1 2 | # slug: tenant-group-1 3 | # - name: Tenant Group 2 4 | # slug: tenant-group-2 5 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/circuit_types.yml: -------------------------------------------------------------------------------- 1 | # - name: VPLS 2 | # slug: vpls 3 | # - name: MPLS 4 | # slug: mpls 5 | # - name: Internet 6 | # slug: internet 7 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/contact_roles.yml: -------------------------------------------------------------------------------- 1 | # - name: New Contact Role 2 | # slug: new-contact-role 3 | # description: This is a new contact role description 4 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/providers.yml: -------------------------------------------------------------------------------- 1 | # - name: Provider1 2 | # slug: provider1 3 | # asn: 1 4 | # - name: Provider2 5 | # slug: provider2 6 | # asn: 3 7 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/asns.yml: -------------------------------------------------------------------------------- 1 | # - asn: 1 2 | # rir: RFC1918 3 | # tenant: tenant1 4 | # - asn: 2 5 | # rir: RFC4193 ULA 6 | # - asn: 3 7 | # rir: RFC3849 8 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/power_panels.yml: -------------------------------------------------------------------------------- 1 | # - name: power panel AMS 1 2 | # site: AMS 1 3 | # - name: power panel SING 1 4 | # site: SING 1 5 | # location: cage 101 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | 10 | [*.py] 11 | indent_size = 4 12 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/clusters.yml: -------------------------------------------------------------------------------- 1 | # - name: cluster1 2 | # type: Hyper-V 3 | # group: Group 1 4 | # tenant: tenant1 5 | # - name: cluster2 6 | # type: Hyper-V 7 | # scope: SING 1 8 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/groups.yml: -------------------------------------------------------------------------------- 1 | # applications: 2 | # users: 3 | # - technical_user 4 | # readers: 5 | # users: 6 | # - reader 7 | # writers: 8 | # users: 9 | # - writer 10 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/manufacturers.yml: -------------------------------------------------------------------------------- 1 | # - name: Manufacturer 1 2 | # slug: manufacturer-1 3 | # - name: Manufacturer 2 4 | # slug: manufacturer-2 5 | # - name: No Name 6 | # slug: no-name 7 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/circuits.yml: -------------------------------------------------------------------------------- 1 | # - cid: Circuit_ID-1 2 | # provider: Provider1 3 | # type: Internet 4 | # tenant: tenant1 5 | # - cid: Circuit_ID-2 6 | # provider: Provider2 7 | # type: MPLS 8 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/site_groups.yml: -------------------------------------------------------------------------------- 1 | # - name: Datacenter 2 | # slug: datacenter 3 | # - name: Office 4 | # slug: office 5 | # - name: Headquaters 6 | # slug: headquarters 7 | # parent: Office 8 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/aggregates.yml: -------------------------------------------------------------------------------- 1 | # - prefix: 10.0.0.0/16 2 | # rir: RFC1918 3 | # tenant: tenant1 4 | # - prefix: fd00:ccdd::/32 5 | # rir: RFC4193 ULA 6 | # - prefix: 2001:db8::/32 7 | # rir: RFC3849 8 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/config_templates.yml: -------------------------------------------------------------------------------- 1 | # - name: configtemplate1 2 | # description: a foobar template 3 | # template_code: | 4 | # hi {{ foo }} 5 | # environment_params: | 6 | # {"foo": "bar"} 7 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/vrfs.yml: -------------------------------------------------------------------------------- 1 | # - enforce_unique: true 2 | # name: vrf1 3 | # tenant: tenant1 4 | # description: main VRF 5 | # - enforce_unique: true 6 | # name: vrf2 7 | # rd: "6500:6500" 8 | # tenant: tenant2 9 | -------------------------------------------------------------------------------- /test/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM netboxcommunity/netbox:v4.4 2 | 3 | COPY ../ /opt/netbox-initializers/ 4 | COPY ./test/config/plugins.py /etc/netbox/config/ 5 | WORKDIR /opt/netbox-initializers/ 6 | RUN /usr/local/bin/uv pip install -e . 7 | WORKDIR /opt/netbox/netbox 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: ❓ Discussion 4 | url: https://github.com/tobiasge/netbox-initializers/discussions 5 | about: "If you're just looking for help, try starting a discussion instead" 6 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/rirs.yml: -------------------------------------------------------------------------------- 1 | # - is_private: true 2 | # name: RFC1918 3 | # slug: rfc1918 4 | # - is_private: true 5 | # name: RFC4193 ULA 6 | # slug: rfc4193-ula 7 | # - is_private: true 8 | # name: RFC3849 9 | # slug: rfc3849 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | - package-ecosystem: "pip" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/service_templates.yml: -------------------------------------------------------------------------------- 1 | # - name: DNS 2 | # protocol: TCP 3 | # ports: 4 | # - 53 5 | # - name: DNS 6 | # protocol: UDP 7 | # ports: 8 | # - 53 9 | # - name: MISC 10 | # protocol: UDP 11 | # ports: 12 | # - 4000 13 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/webhooks.yml: -------------------------------------------------------------------------------- 1 | ## Examples: 2 | 3 | # - name: device_creation 4 | # payload_url: 'http://localhost:8080' 5 | # - name: device_update 6 | # payload_url: 'http://localhost:8080' 7 | # - name: device_delete 8 | # payload_url: 'http://localhost:8080' 9 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/regions.yml: -------------------------------------------------------------------------------- 1 | # - name: Singapore 2 | # slug: singapore 3 | # - name: Amsterdam 4 | # slug: amsterdam 5 | # - name: Downtown 6 | # slug: downtown 7 | # parent: Amsterdam 8 | # - name: Suburbs 9 | # slug: suburbs 10 | # parent: Amsterdam 11 | -------------------------------------------------------------------------------- /.github/workflows/pr-test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Pull request tests 3 | 4 | on: 5 | pull_request: 6 | 7 | jobs: 8 | lint: 9 | uses: ./.github/workflows/linter.yml 10 | tests: 11 | uses: ./.github/workflows/tests.yml 12 | feature-tests: 13 | uses: ./.github/workflows/feature-test.yml 14 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/rack_roles.yml: -------------------------------------------------------------------------------- 1 | # - name: Role 1 2 | # slug: role-1 3 | # color: Pink 4 | # - name: Role 2 5 | # slug: role-2 6 | # color: Cyan 7 | # - name: Role 3 8 | # slug: role-3 9 | # color: Grey 10 | # - name: Role 4 11 | # slug: role-4 12 | # color: Teal 13 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/locations.yml: -------------------------------------------------------------------------------- 1 | # - name: cage 101 2 | # slug: cage-101 3 | # site: SING 1 4 | # - name: Parent Location 5 | # slug: parent-location 6 | # site: SING 1 7 | # - name: Child location 8 | # slug: child-location 9 | # site: SING 1 10 | # parent: Parent Location 11 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/contact_groups.yml: -------------------------------------------------------------------------------- 1 | # - name: Network-Team 2 | # slug: network-team 3 | # description: This is a new contact group for the Network-Team 4 | # - name: New Contact Group 5 | # slug: new-contact-group 6 | # description: This is a new contact group sub under of Network-Team 7 | # parent: Network-Team 8 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/platforms.yml: -------------------------------------------------------------------------------- 1 | # - name: Platform 1 2 | # slug: platform-1 3 | # manufacturer: Manufacturer 1 4 | # - name: Platform 2 5 | # slug: platform-2 6 | # manufacturer: Manufacturer 2 7 | # - name: Platform 3 8 | # slug: platform-3 9 | # manufacturer: No Name 10 | # config_template: configtemplate1 11 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/device_roles.yml: -------------------------------------------------------------------------------- 1 | # - name: switch 2 | # slug: switch 3 | # color: Grey 4 | # - name: router 5 | # slug: router 6 | # color: Cyan 7 | # - name: load-balancer 8 | # slug: load-balancer 9 | # color: Red 10 | # - name: server 11 | # slug: server 12 | # color: Blue 13 | # - name: patchpanel 14 | # slug: patchpanel 15 | # color: Black 16 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/macs.yml: -------------------------------------------------------------------------------- 1 | # - mac_address: 00:01:11:11:11:11 2 | # description: MAC address 1 3 | # - mac_address: 00:01:22:22:22:22 4 | # description: Mac address 2 5 | # - mac_address: 00:01:33:33:33:33 6 | # description: mac address 3 7 | # - mac_address: 00:02:44:44:44:44 8 | # description: mac address 4 9 | # - mac_address: 00:02:55:55:55:55 10 | # description: mac address 5 11 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/tags.yml: -------------------------------------------------------------------------------- 1 | # - name: Tag 1 2 | # slug: tag-1 3 | # color: Pink 4 | # - name: Tag 2 5 | # slug: tag-2 6 | # color: Cyan 7 | # - name: Tag 3 8 | # slug: tag-3 9 | # color: Grey 10 | # - name: Tag 4 11 | # slug: tag-4 12 | # color: Teal 13 | # - name: Tag 5 14 | # slug: tag-5 15 | # color: Teal 16 | # object_types: 17 | # - app: ipam 18 | # model: ipaddress 19 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/power_feeds.yml: -------------------------------------------------------------------------------- 1 | # - name: power feed 1 2 | # power_panel: power panel AMS 1 3 | # voltage: 208 4 | # amperage: 50 5 | # max_utilization: 80 6 | # phase: Single phase 7 | # rack: rack-01 8 | # - name: power feed 2 9 | # power_panel: power panel SING 1 10 | # voltage: 208 11 | # amperage: 50 12 | # max_utilization: 80 13 | # phase: Three-phase 14 | # rack: rack-03 15 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/vlans.yml: -------------------------------------------------------------------------------- 1 | ## Possible Choices: 2 | ## status: 3 | ## - active 4 | ## - reserved 5 | ## - deprecated 6 | ## 7 | ## Examples: 8 | 9 | # - name: vlan1 10 | # site: AMS 1 11 | # status: active 12 | # vid: 5 13 | # role: Main Management 14 | # description: VLAN 5 for MGMT 15 | # - group: VLAN group 2 16 | # name: vlan2 17 | # site: AMS 1 18 | # status: active 19 | # vid: 1300 20 | -------------------------------------------------------------------------------- /src/netbox_initializers/__init__.py: -------------------------------------------------------------------------------- 1 | from netbox.plugins import PluginConfig 2 | 3 | from netbox_initializers.version import VERSION 4 | 5 | 6 | class NetBoxInitializersConfig(PluginConfig): 7 | name = "netbox_initializers" 8 | verbose_name = "NetBox Initializers" 9 | description = "Load initial data into Netbox" 10 | version = VERSION 11 | base_url = "initializers" 12 | min_version = "4.4.0" 13 | max_version = "4.4.99" 14 | 15 | 16 | config = NetBoxInitializersConfig 17 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/services.yml: -------------------------------------------------------------------------------- 1 | # - name: DNS 2 | # protocol: TCP 3 | # ports: 4 | # - 53 5 | # parent_type: virtualization.virtualmachine 6 | # parent_name: virtual machine 1 7 | # - name: DNS 8 | # protocol: UDP 9 | # ports: 10 | # - 53 11 | # parent_type: virtualization.virtualmachine 12 | # parent_name: virtual machine 1 13 | # - name: MISC 14 | # protocol: UDP 15 | # ports: 16 | # - 4000 17 | # parent_type: dcim.device 18 | # parent_name: server01 19 | -------------------------------------------------------------------------------- /test/gh-functions.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ### 4 | # A regular echo, that only prints if ${GITHUB_ACTIONS} is defined. 5 | ### 6 | gh_echo() { 7 | if [ -n "${GITHUB_ACTIONS}" ]; then 8 | echo "${@}" 9 | fi 10 | } 11 | 12 | ### 13 | # Prints the output to the file defined in ${GITHUB_ENV}. 14 | # Only executes if ${GITHUB_ACTIONS} is defined. 15 | # Example Usage: gh_env "FOO_VAR=bar_value" 16 | ### 17 | gh_env() { 18 | if [ -n "${GITHUB_ACTIONS}" ]; then 19 | echo "${@}" >>"${GITHUB_ENV}" 20 | fi 21 | } 22 | -------------------------------------------------------------------------------- /test/env/netbox.env: -------------------------------------------------------------------------------- 1 | CORS_ORIGIN_ALLOW_ALL=True 2 | DB_HOST=postgres 3 | DB_NAME=netbox 4 | DB_PASSWORD=igohhi0iewoo+chuy4G 5 | DB_USER=netbox 6 | REDIS_CACHE_DATABASE=1 7 | REDIS_CACHE_HOST=redis-cache 8 | REDIS_CACHE_INSECURE_SKIP_TLS_VERIFY=false 9 | REDIS_CACHE_PASSWORD=AquaeTh;ae7Piev0quah 10 | REDIS_CACHE_SSL=false 11 | REDIS_DATABASE=0 12 | REDIS_HOST=redis 13 | REDIS_INSECURE_SKIP_TLS_VERIFY=false 14 | REDIS_PASSWORD=aC4eic9if9de4eHi@kah 15 | REDIS_SSL=false 16 | SECRET_KEY=yam+ie6Uhou5ciGaez7Psheihae*Nga3wohz9ietsae8Hu:chung:aeGeat9 17 | SKIP_SUPERUSER=true 18 | WEBHOOKS_ENABLED=true 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: ✨ Feature Request 3 | description: Propose a new NetBox initializers feature 4 | labels: ["enhancement"] 5 | body: 6 | - type: textarea 7 | attributes: 8 | label: Proposed functionality 9 | description: > 10 | Describe in detail the new feature or behavior you are proposing. 11 | validations: 12 | required: true 13 | - type: textarea 14 | attributes: 15 | label: Use case 16 | description: > 17 | Explain the use case the feature will enable. 18 | validations: 19 | required: true 20 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/custom_links.yml: -------------------------------------------------------------------------------- 1 | ## Possible Choices: 2 | ## new_window: 3 | ## - True 4 | ## - False 5 | ## content_type: 6 | ## - device 7 | ## - site 8 | ## - any-other-content-type 9 | ## 10 | ## Examples: 11 | 12 | # - name: link_to_repo 13 | # link_text: 'Link to Netbox Docker' 14 | # link_url: 'https://github.com/netbox-community/netbox-docker' 15 | # new_window: False 16 | # content_type: device 17 | # - name: link_to_localhost 18 | # link_text: 'Link to localhost' 19 | # link_url: 'http://localhost' 20 | # new_window: True 21 | # content_type: device 22 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/users.yml: -------------------------------------------------------------------------------- 1 | # technical_user: 2 | # api_token: 0123456789technicaluser789abcdef01234567 # must be looooong! #gitleaks:allow 3 | # reader: 4 | # password: reader #gitleaks:allow 5 | # writer: 6 | # password: writer #gitleaks:allowt 7 | # api_token: "" # a token is generated automatically unless the value is explicity set to empty #gitleaks:allow 8 | # jdoe: 9 | # first_name: John 10 | # last_name: Doe 11 | # api_token: 0123456789jdoe789abcdef01234567jdoe #gitleaks:allow 12 | # is_active: True 13 | # is_superuser: False 14 | # email: john.doe@example.com 15 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Run tests 3 | 4 | on: 5 | workflow_call: 6 | 7 | jobs: 8 | tests: 9 | runs-on: ubuntu-latest 10 | name: Run initializer test script 11 | steps: 12 | - id: git-checkout 13 | name: Checkout 14 | uses: actions/checkout@v6 15 | - id: test-script-1 16 | name: Test the initializers (First run) 17 | env: 18 | KEEP_VOLUMES: "true" 19 | run: | 20 | cd test 21 | ./test.sh 22 | - id: test-script-2 23 | name: Test the initializers (Second run) 24 | run: | 25 | cd test 26 | ./test.sh 27 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/utils.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.models import ContentType 2 | 3 | 4 | def get_scope_details(scope: dict, allowed_termination_types: list): 5 | try: 6 | scope_type = ContentType.objects.get(app_label__in=["dcim", "circuits"], model=scope["type"]) 7 | if scope["type"] not in allowed_termination_types: 8 | raise ValueError(f"{scope['type']} scope type is not permitted on {scope_type.app_label}") 9 | except ContentType.DoesNotExist: 10 | raise ValueError(f"⚠️ Invalid scope type: {scope['type']}") 11 | 12 | scope_id = scope_type.model_class().objects.get(name=scope["name"]).id 13 | return scope_type, scope_id 14 | -------------------------------------------------------------------------------- /docs/dev/Branches.md: -------------------------------------------------------------------------------- 1 | # Git branching model 2 | 3 | ## `main` branch 4 | 5 | All new features and bugfixes will first be merged into the main branch. The `Major.Minor` version number will follow those from Netbox with which the plugin is compatible. 6 | 7 | ## `netbox/vX.Y` branches 8 | 9 | After a new Netbox release is published the state of the main branch is copied into a new `netbox/vX.Y` branch. For example after the release of Netbox 3.4 the `main` branch will be copied to `netbox/v3.3`. Only after that copy is made changes for Netbox 3.4 can be merged into `main`. 10 | These branches are in maintenance mode. No new feature will be merged but bugfixes can be backported from `main` if they are relevant. 11 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/vlan_groups.yml: -------------------------------------------------------------------------------- 1 | # - name: VLAN group 1 2 | # scope_type: dcim.region 3 | # scope: Amsterdam 4 | # slug: vlan-group-1 5 | # - name: VLAN group 2 6 | # scope_type: dcim.site 7 | # scope: AMS 1 8 | # slug: vlan-group-2 9 | # - name: VLAN group 3 10 | # scope_type: dcim.location 11 | # scope: cage 101 12 | # slug: vlan-group-3 13 | # - name: VLAN group 4 14 | # scope_type: dcim.rack 15 | # scope: rack-01 16 | # slug: vlan-group-4 17 | # - name: VLAN group 5 18 | # scope_type: virtualization.cluster 19 | # scope: cluster1 20 | # slug: vlan-group-5 21 | # - name: VLAN group 6 22 | # scope_type: virtualization.clustergroup 23 | # scope: Group 1 24 | # slug: vlan-group-6 25 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/macs.py: -------------------------------------------------------------------------------- 1 | from dcim.models import MACAddress 2 | 3 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 4 | 5 | 6 | class MACAddressInitializer(BaseInitializer): 7 | data_file_name = "macs.yml" 8 | 9 | def load_data(self): 10 | macs = self.load_yaml() 11 | if macs is None: 12 | return 13 | 14 | for mac in macs: 15 | tags = mac.pop("tags", None) 16 | macaddress, created = MACAddress.objects.get_or_create(**mac) 17 | 18 | if created: 19 | print("🗺️ Created MAC Address", macaddress.mac_address) 20 | 21 | self.set_tags(macaddress, tags) 22 | 23 | 24 | register_initializer("macs", MACAddressInitializer) 25 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/racks.yml: -------------------------------------------------------------------------------- 1 | ## Possible Choices: 2 | ## width: 3 | ## - 19 4 | ## - 23 5 | ## outer_unit: 6 | ## - mm 7 | ## - in 8 | ## 9 | ## Examples: 10 | 11 | # - site: AMS 1 12 | # name: rack-01 13 | # role: Role 1 14 | # rack_type: rack-type-1 15 | # width: 19 16 | # u_height: 47 17 | # custom_field_data: 18 | # text_field: Description 19 | # - site: AMS 2 20 | # name: rack-02 21 | # role: Role 2 22 | # rack_type: rack-type-2 23 | # width: 19 24 | # u_height: 47 25 | # custom_field_data: 26 | # text_field: Description 27 | # - site: SING 1 28 | # name: rack-03 29 | # location: cage 101 30 | # role: Role 3 31 | # rack_type: rack-type-3 32 | # width: 19 33 | # u_height: 47 34 | # custom_field_data: 35 | # text_field: Description 36 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/rirs.py: -------------------------------------------------------------------------------- 1 | from ipam.models import RIR 2 | 3 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 4 | 5 | 6 | class RIRInitializer(BaseInitializer): 7 | data_file_name = "rirs.yml" 8 | 9 | def load_data(self): 10 | rirs = self.load_yaml() 11 | if rirs is None: 12 | return 13 | 14 | for params in rirs: 15 | tags = params.pop("tags", None) 16 | matching_params, defaults = self.split_params(params) 17 | rir, created = RIR.objects.get_or_create(**matching_params, defaults=defaults) 18 | 19 | if created: 20 | print("🗺️ Created RIR", rir.name) 21 | 22 | self.set_tags(rir, tags) 23 | 24 | 25 | register_initializer("rirs", RIRInitializer) 26 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/contacts.yml: -------------------------------------------------------------------------------- 1 | # - name: Lee Widget 2 | # title: CEO of Widget Corp 3 | # phone: 221-555-1212 4 | # email: widgetCEO@widgetcorp.com 5 | # address: 1200 Nowhere Blvd, Scranton NJ, 555111 6 | # comments: This is a very important contact 7 | # - name: Ali Gator 8 | # groups: 9 | # - Network-Team 10 | # title: Consultant for Widget Corp 11 | # phone: 221-555-1213 12 | # email: Consultant@widgetcorp.com 13 | # address: 1200 Nowhere Blvd, Scranton NJ, 555111 14 | # comments: This is a very important contact 15 | # - name: Karlchen Maier 16 | # groups: 17 | # - New Contact Group 18 | # title: COO of Widget Corp 19 | # phone: 221-555-1214 20 | # email: Karlchen@widgetcorp.com 21 | # address: 1200 Nowhere Blvd, Scranton NJ, 555111 22 | # comments: This is a very important contact 23 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/prefix_vlan_roles.py: -------------------------------------------------------------------------------- 1 | from ipam.models import Role 2 | 3 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 4 | 5 | 6 | class RoleInitializer(BaseInitializer): 7 | data_file_name = "prefix_vlan_roles.yml" 8 | 9 | def load_data(self): 10 | roles = self.load_yaml() 11 | if roles is None: 12 | return 13 | for params in roles: 14 | tags = params.pop("tags", None) 15 | matching_params, defaults = self.split_params(params) 16 | role, created = Role.objects.get_or_create(**matching_params, defaults=defaults) 17 | 18 | if created: 19 | print("⛹️‍ Created Prefix/VLAN Role", role.name) 20 | 21 | self.set_tags(role, tags) 22 | 23 | 24 | register_initializer("prefix_vlan_roles", RoleInitializer) 25 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/sites.yml: -------------------------------------------------------------------------------- 1 | # - name: AMS 1 2 | # slug: ams1 3 | # region: Downtown 4 | # status: active 5 | # facility: Amsterdam 1 6 | #. group: Office 7 | # custom_field_data: 8 | # text_field: Description for AMS1 9 | # - name: AMS 2 10 | # slug: ams2 11 | # asns: 12 | # - 2 13 | # region: Downtown 14 | # status: active 15 | # facility: Amsterdam 2 16 | # custom_field_data: 17 | # text_field: Description for AMS2 18 | # - name: AMS 3 19 | # slug: ams3 20 | # region: Suburbs 21 | # status: active 22 | # facility: Amsterdam 3 23 | # tenant: tenant1 24 | # custom_field_data: 25 | # text_field: Description for AMS3 26 | # - name: SING 1 27 | # slug: sing1 28 | # region: Singapore 29 | # status: active 30 | # facility: Singapore 1 31 | # tenant: tenant2 32 | # custom_field_data: 33 | # text_field: Description for SING1 34 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Publish release 3 | 4 | on: 5 | release: 6 | types: 7 | - "released" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | lint: 12 | uses: ./.github/workflows/linter.yml 13 | tests: 14 | uses: ./.github/workflows/tests.yml 15 | release: 16 | runs-on: ubuntu-latest 17 | name: Release to PyPi 18 | needs: [lint, tests] 19 | steps: 20 | - id: git-checkout 21 | name: Checkout 22 | uses: actions/checkout@v6 23 | - uses: actions/setup-python@v6 24 | with: 25 | python-version: "3.12" 26 | - name: Install uv 27 | uses: astral-sh/setup-uv@v7 28 | - id: build-and-publish 29 | name: Build and publish to pypi 30 | env: 31 | UV_PUBLISH_USERNAME: __token__ 32 | UV_PUBLISH_PASSWORD: ${{ secrets.PYPI_TOKEN }} 33 | run: | 34 | uv build 35 | uv publish 36 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/config_contexts.yml: -------------------------------------------------------------------------------- 1 | # - name: configcontext1 2 | # description: a foobar context 3 | # weight: 1000 4 | # is_active: true 5 | # data: 6 | # foor: bar 7 | # baz: 8 | # - one 9 | # - two 10 | # - three 11 | # regions: 12 | # - Singapore 13 | # - Amsterdam 14 | # sites: 15 | # - AMS 1 16 | # - AMS 2 17 | # locations: 18 | # - cage 101 19 | # - Parent Location 20 | # device_types: 21 | # - Model 1 22 | # - Model 2 23 | # roles: 24 | # - switch 25 | # - server 26 | # platforms: 27 | # - Platform 1 28 | # - Platform 2 29 | # cluster_types: 30 | # - Hyper-V 31 | # cluster_groups: 32 | # - Group 1 33 | # - Group 2 34 | # clusters: 35 | # - cluster1 36 | # - cluster2 37 | # tenant_groups: 38 | # - Tenant Group 1 39 | # - Tenant Group 2 40 | # tenants: 41 | # - tenant1 42 | # - tenant2 43 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: ["main"] 9 | schedule: 10 | - cron: "38 4 * * 2" 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | permissions: 17 | actions: read 18 | contents: read 19 | security-events: write 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: ["python"] 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@v6 27 | - name: Initialize CodeQL 28 | uses: github/codeql-action/init@v4 29 | with: 30 | languages: ${{ matrix.language }} 31 | - name: Perform CodeQL Analysis 32 | uses: github/codeql-action/analyze@v4 33 | with: 34 | category: "/language:${{matrix.language}}" 35 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/virtualization_interfaces.yml: -------------------------------------------------------------------------------- 1 | # - description: Network Interface 1 2 | # enabled: true 3 | # primary_mac_address: 00:01:11:11:11:11 4 | # mtu: 1500 5 | # name: Network Interface 1 6 | # virtual_machine: virtual machine 1 7 | # - description: Network Interface 2 8 | # enabled: true 9 | # mac_addresses: 10 | # - 00:01:22:22:22:22 11 | # - 00:01:33:33:33:33 12 | # primary_mac_address: 00:01:33:33:33:33 13 | # mtu: 1500 14 | # name: Network Interface 2 15 | # virtual_machine: virtual machine 1 16 | # - description: Network Interface 3 17 | # enabled: true 18 | # mtu: 1500 19 | # name: Network Interface 3 20 | # virtual_machine: virtual machine 2 21 | # - description: Network Interface 4 22 | # enabled: true 23 | # mac_addresses: 24 | # - 00:02:44:44:44:44 25 | # - 00:02:55:55:55:55 26 | # mtu: 1500 27 | # name: Network Interface 4 28 | # virtual_machine: virtual machine 2 29 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/manufacturers.py: -------------------------------------------------------------------------------- 1 | from dcim.models import Manufacturer 2 | 3 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 4 | 5 | 6 | class ManufacturerInitializer(BaseInitializer): 7 | data_file_name = "manufacturers.yml" 8 | 9 | def load_data(self): 10 | manufacturers = self.load_yaml() 11 | if manufacturers is None: 12 | return 13 | for params in manufacturers: 14 | tags = params.pop("tags", None) 15 | matching_params, defaults = self.split_params(params) 16 | manufacturer, created = Manufacturer.objects.get_or_create( 17 | **matching_params, defaults=defaults 18 | ) 19 | 20 | if created: 21 | print("🏭 Created Manufacturer", manufacturer.name) 22 | 23 | self.set_tags(manufacturer, tags) 24 | 25 | 26 | register_initializer("manufacturers", ManufacturerInitializer) 27 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/tenant_groups.py: -------------------------------------------------------------------------------- 1 | from tenancy.models import TenantGroup 2 | 3 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 4 | 5 | 6 | class TenantGroupInitializer(BaseInitializer): 7 | data_file_name = "tenant_groups.yml" 8 | 9 | def load_data(self): 10 | tenant_groups = self.load_yaml() 11 | if tenant_groups is None: 12 | return 13 | for params in tenant_groups: 14 | tags = params.pop("tags", None) 15 | matching_params, defaults = self.split_params(params) 16 | tenant_group, created = TenantGroup.objects.get_or_create( 17 | **matching_params, defaults=defaults 18 | ) 19 | 20 | if created: 21 | print("🔳 Created Tenant Group", tenant_group.name) 22 | 23 | self.set_tags(tenant_group, tags) 24 | 25 | 26 | register_initializer("tenant_groups", TenantGroupInitializer) 27 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/cluster_types.py: -------------------------------------------------------------------------------- 1 | from virtualization.models import ClusterType 2 | 3 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 4 | 5 | 6 | class ClusterTypesInitializer(BaseInitializer): 7 | data_file_name = "cluster_types.yml" 8 | 9 | def load_data(self): 10 | cluster_types = self.load_yaml() 11 | if cluster_types is None: 12 | return 13 | for params in cluster_types: 14 | tags = params.pop("tags", None) 15 | matching_params, defaults = self.split_params(params) 16 | cluster_type, created = ClusterType.objects.get_or_create( 17 | **matching_params, defaults=defaults 18 | ) 19 | 20 | if created: 21 | print("🧰 Created Cluster Type", cluster_type.name) 22 | self.set_tags(cluster_type, tags) 23 | 24 | 25 | register_initializer("cluster_types", ClusterTypesInitializer) 26 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/cluster_groups.py: -------------------------------------------------------------------------------- 1 | from virtualization.models import ClusterGroup 2 | 3 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 4 | 5 | 6 | class ClusterGroupInitializer(BaseInitializer): 7 | data_file_name = "cluster_groups.yml" 8 | 9 | def load_data(self): 10 | cluster_groups = self.load_yaml() 11 | if cluster_groups is None: 12 | return 13 | for params in cluster_groups: 14 | tags = params.pop("tags", None) 15 | matching_params, defaults = self.split_params(params) 16 | cluster_group, created = ClusterGroup.objects.get_or_create( 17 | **matching_params, defaults=defaults 18 | ) 19 | 20 | if created: 21 | print("🗄️ Created Cluster Group", cluster_group.name) 22 | self.set_tags(cluster_group, tags) 23 | 24 | 25 | register_initializer("cluster_groups", ClusterGroupInitializer) 26 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/groups.py: -------------------------------------------------------------------------------- 1 | from users.models import Group, User 2 | 3 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 4 | 5 | 6 | class GroupInitializer(BaseInitializer): 7 | data_file_name = "groups.yml" 8 | 9 | def load_data(self): 10 | groups = self.load_yaml() 11 | if groups is None: 12 | return 13 | 14 | for groupname, group_details in groups.items(): 15 | group, created = Group.objects.get_or_create(name=groupname) 16 | if created: 17 | print("👥 Created group", groupname) 18 | for username in group_details.get("users", []): 19 | user = User.objects.get(username=username) 20 | if user: 21 | group.users.add(user) 22 | print(" 👤 Assigned user %s to group %s" % (username, group.name)) 23 | group.save() 24 | 25 | 26 | register_initializer("groups", GroupInitializer) 27 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/virtual_machines.yml: -------------------------------------------------------------------------------- 1 | ## Possible Choices: 2 | ## status: 3 | ## - active 4 | ## - offline 5 | ## - staged 6 | ## 7 | ## Examples: 8 | 9 | # - cluster: cluster1 10 | # comments: VM1 11 | # disk: 200 12 | # memory: 4096 13 | # name: virtual machine 1 14 | # platform: Platform 2 15 | # status: active 16 | # tenant: tenant1 17 | # vcpus: 8 18 | # - cluster: cluster1 19 | # comments: VM2 20 | # disk: 100 21 | # memory: 2048 22 | # name: virtual machine 2 23 | # platform: Platform 2 24 | # primary_ip4: 10.1.1.10/24 25 | # primary_ip6: 2001:db8:a000:1::10/64 26 | # status: active 27 | # tenant: tenant1 28 | # vcpus: 8 29 | # - cluster: cluster2 30 | # comments: VM3 31 | # disk: 250 32 | # memory: 4096 33 | # name: virtual machine 3 34 | # platform: Platform 2 35 | # primary_ip4: 10.1.1.11/24 36 | # primary_ip6: 2001:db8:a000:1::11/64 37 | # site: SING 1 38 | # status: active 39 | # tenant: tenant1 40 | # vcpus: 8 41 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/service_templates.py: -------------------------------------------------------------------------------- 1 | from ipam.models import ServiceTemplate 2 | 3 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 4 | 5 | MATCH_PARAMS = ["name"] 6 | 7 | 8 | class ServiceTemplateInitializer(BaseInitializer): 9 | data_file_name = "service_templates.yml" 10 | 11 | def load_data(self): 12 | service_templates = self.load_yaml() 13 | if service_templates is None: 14 | return 15 | for params in service_templates: 16 | tags = params.pop("tags", None) 17 | matching_params, defaults = self.split_params(params, MATCH_PARAMS) 18 | service_template, created = ServiceTemplate.objects.get_or_create( 19 | **matching_params, defaults=defaults 20 | ) 21 | 22 | if created: 23 | print("🧰 Created Service Template", service_template.name) 24 | 25 | self.set_tags(service_template, tags) 26 | 27 | 28 | register_initializer("service_templates", ServiceTemplateInitializer) 29 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/interfaces.yml: -------------------------------------------------------------------------------- 1 | ## Possible Choices: 2 | ## type: 3 | ## - virtual 4 | ## - lag 5 | ## - 1000base-t 6 | ## - ... and many more. See for yourself: 7 | ## https://github.com/netbox-community/netbox/blob/295d4f0394b431351c0cb2c3ecc791df68c6c2fb/netbox/dcim/choices.py#L510 8 | ## 9 | ## Examples: 10 | 11 | # - device: server01 12 | # name: ath0 13 | # type: 1000base-t 14 | # lag: ae0 15 | # bridge: br0 16 | # - device: server01 17 | # name: ath1 18 | # type: 1000base-t 19 | # parent: ath0 20 | # - device: server01 21 | # enabled: true 22 | # type: 1000base-x-sfp 23 | # name: to-server02 24 | # - device: server02 25 | # enabled: true 26 | # type: 1000base-x-sfp 27 | # name: to-server01 28 | # - device: server02 29 | # enabled: true 30 | # type: 1000base-t 31 | # name: eth0 32 | # untagged_vlan: vlan2 33 | # - device: server02 34 | # enabled: true 35 | # type: virtual 36 | # name: loopback 37 | 38 | ## Example to add attributes on a templated interface 39 | # - name: Ethernet1 40 | # mtu: 9100 41 | # device: gns3-tor 42 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/users.py: -------------------------------------------------------------------------------- 1 | from django.utils.crypto import get_random_string 2 | from users.models import Token, User 3 | 4 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 5 | 6 | 7 | class UserInitializer(BaseInitializer): 8 | data_file_name = "users.yml" 9 | 10 | def load_data(self): 11 | users = self.load_yaml() 12 | if users is None: 13 | return 14 | 15 | for username, user_details in users.items(): 16 | api_token = user_details.pop("api_token", Token.generate_key()) 17 | password = user_details.pop("password", get_random_string(length=25)) 18 | user, created = User.objects.get_or_create(username=username, defaults=user_details) 19 | if created: 20 | user.set_password(password) 21 | user.save() 22 | if api_token: 23 | Token.objects.get_or_create(user=user, key=api_token) 24 | print("👤 Created user", username) 25 | 26 | 27 | register_initializer("users", UserInitializer) 28 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/circuit_types.py: -------------------------------------------------------------------------------- 1 | from circuits.models import CircuitType 2 | 3 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 4 | 5 | 6 | class CircuitTypeInitializer(BaseInitializer): 7 | data_file_name = "circuit_types.yml" 8 | 9 | def load_data(self): 10 | circuit_types = self.load_yaml() 11 | if circuit_types is None: 12 | return 13 | for params in circuit_types: 14 | tags = params.pop("tags", None) 15 | custom_field_data = self.pop_custom_fields(params) 16 | 17 | matching_params, defaults = self.split_params(params) 18 | circuit_type, created = CircuitType.objects.get_or_create( 19 | **matching_params, defaults=defaults 20 | ) 21 | 22 | if created: 23 | print("⚡ Created Circuit Type", circuit_type.name) 24 | 25 | self.set_custom_fields_values(circuit_type, custom_field_data) 26 | self.set_tags(circuit_type, tags) 27 | 28 | 29 | register_initializer("circuit_types", CircuitTypeInitializer) 30 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/contact_roles.py: -------------------------------------------------------------------------------- 1 | from tenancy.models import ContactRole 2 | 3 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 4 | 5 | 6 | class ContactRoleInitializer(BaseInitializer): 7 | data_file_name = "contact_roles.yml" 8 | 9 | def load_data(self): 10 | contact_roles = self.load_yaml() 11 | if contact_roles is None: 12 | return 13 | for params in contact_roles: 14 | custom_field_data = self.pop_custom_fields(params) 15 | tags = params.pop("tags", None) 16 | 17 | matching_params, defaults = self.split_params(params) 18 | contact_role, created = ContactRole.objects.get_or_create( 19 | **matching_params, defaults=defaults 20 | ) 21 | 22 | if created: 23 | print("🔳 Created Contact Role", contact_role.name) 24 | 25 | self.set_custom_fields_values(contact_role, custom_field_data) 26 | self.set_tags(contact_role, tags) 27 | 28 | 29 | register_initializer("contact_roles", ContactRoleInitializer) 30 | -------------------------------------------------------------------------------- /.github/workflows/feature-test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Test with Netbox feature branch 3 | 4 | on: 5 | schedule: 6 | - cron: "35 07 * * *" 7 | workflow_dispatch: 8 | workflow_call: 9 | 10 | jobs: 11 | feature-tests: 12 | runs-on: ubuntu-latest 13 | name: Run initializer test script 14 | steps: 15 | - id: git-checkout 16 | name: Checkout 17 | uses: actions/checkout@v6 18 | - id: code-update 19 | name: Change test target to feature branch of Netbox 20 | run: | 21 | sed -i '/max_version/d' src/netbox_initializers/__init__.py 22 | sed -i '/min_version/d' src/netbox_initializers/__init__.py 23 | sed -i 's/FROM netboxcommunity\/netbox:v[0-9].[0-9]/FROM netboxcommunity\/netbox:feature/g' test/Dockerfile 24 | - id: test-script-1 25 | name: Test the initializers (First run) 26 | env: 27 | KEEP_VOLUMES: "true" 28 | run: | 29 | cd test 30 | ./test.sh 31 | - id: test-script-2 32 | name: Test the initializers (Second run) 33 | run: | 34 | cd test 35 | ./test.sh 36 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/prefixes.yml: -------------------------------------------------------------------------------- 1 | ## Possible Choices: 2 | ## status: 3 | ## - container 4 | ## - active 5 | ## - reserved 6 | ## - deprecated 7 | ## scope: 8 | ## type: 9 | ## - region 10 | ## - sitegroup 11 | ## - site 12 | ## - location 13 | ## 14 | ## Examples: 15 | 16 | # - description: prefix1 17 | # prefix: 10.1.1.0/24 18 | # scope: 19 | # type: site 20 | # name: AMS 1 21 | # status: active 22 | # tenant: tenant1 23 | # vlan: vlan1 24 | # - description: prefix2 25 | # prefix: 10.1.2.0/24 26 | # scope: 27 | # type: site 28 | # name: AMS 2 29 | # status: active 30 | # tenant: tenant2 31 | # vlan: vlan2 32 | # is_pool: true 33 | # vrf: vrf2 34 | # - description: ipv6 prefix1 35 | # prefix: 2001:db8:a000:1::/64 36 | # scope: 37 | # type: site 38 | # name: AMS 2 39 | # status: active 40 | # tenant: tenant2 41 | # vlan: vlan2 42 | # - description: ipv6 prefix2 43 | # prefix: 2001:db8:b000:1::/64 44 | # scope: 45 | # type: location 46 | # name: cage 101 47 | # status: active 48 | # tenant: tenant2 49 | # vlan: vlan1 50 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/rack_types.py: -------------------------------------------------------------------------------- 1 | from dcim.models import Manufacturer, RackType 2 | 3 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 4 | 5 | MATCH_PARAMS = ["slug"] 6 | REQUIRED_ASSOCS = {"manufacturer": (Manufacturer, "slug")} 7 | 8 | 9 | class RackTypeInitializer(BaseInitializer): 10 | data_file_name = "rack_types.yml" 11 | 12 | def load_data(self): 13 | rack_types = self.load_yaml() 14 | if rack_types is None: 15 | return 16 | for params in rack_types: 17 | for assoc, details in REQUIRED_ASSOCS.items(): 18 | model, field = details 19 | query = {field: params.pop(assoc)} 20 | 21 | params[assoc] = model.objects.get(**query) 22 | 23 | matching_params, defaults = self.split_params(params, MATCH_PARAMS) 24 | rack_type, created = RackType.objects.get_or_create( 25 | **matching_params, defaults=defaults 26 | ) 27 | 28 | if created: 29 | print("🔳 Created rack type", rack_type.model) 30 | 31 | 32 | register_initializer("rack_types", RackTypeInitializer) 33 | -------------------------------------------------------------------------------- /.github/workflows/linter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Run github/super-linter 3 | 4 | on: 5 | workflow_call: 6 | 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-latest 10 | name: Checks syntax of our code 11 | steps: 12 | - id: git-checkout 13 | name: Checkout 14 | uses: actions/checkout@v6 15 | with: 16 | # Full git history is needed to get a proper 17 | # list of changed files within `super-linter` 18 | fetch-depth: 0 19 | - name: Lint Code Base 20 | uses: github/super-linter@v7 21 | env: 22 | DEFAULT_BRANCH: main 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | SUPPRESS_POSSUM: true 25 | VALIDATE_ALL_CODEBASE: false 26 | VALIDATE_CHECKOV: false 27 | VALIDATE_JSCPD: false 28 | VALIDATE_PYTHON_BLACK: false 29 | VALIDATE_PYTHON_ISORT: false 30 | VALIDATE_PYTHON_MYPY: false 31 | VALIDATE_PYTHON_PYINK: false 32 | VALIDATE_PYTHON_PYLINT: false 33 | LINTER_RULES_PATH: / 34 | FILTER_REGEX_EXCLUDE: (.*/)?(LICENSE) 35 | YAML_CONFIG_FILE: .yamllint.yaml 36 | PYTHON_FLAKE8_CONFIG_FILE: .flake8 37 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/regions.py: -------------------------------------------------------------------------------- 1 | from dcim.models import Region 2 | 3 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 4 | 5 | OPTIONAL_ASSOCS = {"parent": (Region, "name")} 6 | 7 | 8 | class RegionInitializer(BaseInitializer): 9 | data_file_name = "regions.yml" 10 | 11 | def load_data(self): 12 | regions = self.load_yaml() 13 | if regions is None: 14 | return 15 | for params in regions: 16 | tags = params.pop("tags", None) 17 | 18 | for assoc, details in OPTIONAL_ASSOCS.items(): 19 | if assoc in params: 20 | model, field = details 21 | query = {field: params.pop(assoc)} 22 | 23 | params[assoc] = model.objects.get(**query) 24 | 25 | matching_params, defaults = self.split_params(params) 26 | region, created = Region.objects.get_or_create(**matching_params, defaults=defaults) 27 | 28 | if created: 29 | print("🌐 Created region", region.name) 30 | 31 | self.set_tags(region, tags) 32 | 33 | 34 | register_initializer("regions", RegionInitializer) 35 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/ip_addresses.yml: -------------------------------------------------------------------------------- 1 | ## Possible Choices: 2 | ## status: 3 | ## - active 4 | ## - reserved 5 | ## - deprecated 6 | ## - dhcp 7 | ## role: 8 | ## - loopback 9 | ## - secondary 10 | ## - anycast 11 | ## - vip 12 | ## - vrrp 13 | ## - hsrp 14 | ## - glbp 15 | ## - carp 16 | ## 17 | ## Examples: 18 | 19 | # - address: 10.1.1.1/24 20 | # device: server01 21 | # interface: to-server02 22 | # status: active 23 | # vrf: vrf1 24 | # - address: 10.1.1.1/24 25 | # device: server02 26 | # interface: to-server01 27 | # status: active 28 | # vrf: vrf1 29 | # - address: 2001:db8:a000:1::1/64 30 | # device: server01 31 | # interface: to-server02 32 | # status: active 33 | # vrf: vrf1 34 | # - address: 10.1.1.2/24 35 | # device: server02 36 | # interface: to-server01 37 | # status: active 38 | # - address: 2001:db8:a000:1::2/64 39 | # device: server02 40 | # interface: to-server01 41 | # status: active 42 | # - address: 10.1.1.10/24 43 | # description: reserved IP 44 | # status: reserved 45 | # tenant: tenant1 46 | # - address: 2001:db8:a000:1::10/64 47 | # description: reserved IP 48 | # status: reserved 49 | # tenant: tenant1 50 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/rack_roles.py: -------------------------------------------------------------------------------- 1 | from dcim.models import RackRole 2 | from netbox.choices import ColorChoices 3 | 4 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 5 | 6 | 7 | class RackRoleInitializer(BaseInitializer): 8 | data_file_name = "rack_roles.yml" 9 | 10 | def load_data(self): 11 | rack_roles = self.load_yaml() 12 | if rack_roles is None: 13 | return 14 | for params in rack_roles: 15 | tags = params.pop("tags", None) 16 | if "color" in params: 17 | color = params.pop("color") 18 | 19 | for color_tpl in ColorChoices: 20 | if color in color_tpl: 21 | params["color"] = color_tpl[0] 22 | 23 | matching_params, defaults = self.split_params(params) 24 | rack_role, created = RackRole.objects.get_or_create( 25 | **matching_params, defaults=defaults 26 | ) 27 | 28 | if created: 29 | print("🎨 Created rack role", rack_role.name) 30 | 31 | self.set_tags(rack_role, tags) 32 | 33 | 34 | register_initializer("rack_roles", RackRoleInitializer) 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Bug Report 3 | description: Report a reproducible bug 4 | labels: ["bug"] 5 | body: 6 | - type: input 7 | attributes: 8 | label: NetBox version 9 | description: What version of NetBox are you currently running? 10 | placeholder: v3.x.x 11 | validations: 12 | required: true 13 | - type: input 14 | attributes: 15 | label: NetBox initializers version 16 | description: What version of NetBox initializers are you currently running? 17 | placeholder: v3.x.x 18 | validations: 19 | required: true 20 | - type: textarea 21 | attributes: 22 | label: Expected Behavior 23 | description: What did you expect to happen? 24 | validations: 25 | required: true 26 | - type: textarea 27 | attributes: 28 | label: Observed Behavior 29 | description: What happened instead? 30 | validations: 31 | required: true 32 | - type: textarea 33 | attributes: 34 | label: Example YAML 35 | description: Provide an example for the YAML file that is causing your issue 36 | placeholder: | 37 | --- 38 | render: YAML 39 | validations: 40 | required: true 41 | 42 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/webhooks.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.models import ContentType 2 | from extras.models import Webhook 3 | 4 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 5 | 6 | 7 | def get_content_type_id(hook_name, content_type): 8 | try: 9 | return ContentType.objects.get(model=content_type).id 10 | except ContentType.DoesNotExist as ex: 11 | print("⚠️ Webhook '{0}': The object_type '{1}' is unknown.".format(hook_name, content_type)) 12 | raise ex 13 | 14 | 15 | class WebhookInitializer(BaseInitializer): 16 | data_file_name = "webhooks.yml" 17 | 18 | def load_data(self): 19 | webhooks = self.load_yaml() 20 | if webhooks is None: 21 | return 22 | for hook in webhooks: 23 | tags = hook.pop("tags", None) 24 | matching_params, defaults = self.split_params(hook) 25 | webhook, created = Webhook.objects.get_or_create(**matching_params, defaults=defaults) 26 | 27 | if created: 28 | print("🪝 Created Webhook {0}".format(webhook.name)) 29 | 30 | self.set_tags(webhook, tags) 31 | 32 | 33 | register_initializer("webhooks", WebhookInitializer) 34 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/locations.py: -------------------------------------------------------------------------------- 1 | from dcim.models import Location, Site 2 | 3 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 4 | 5 | OPTIONAL_ASSOCS = {"site": (Site, "name"), "parent": (Location, "name")} 6 | 7 | 8 | class LocationInitializer(BaseInitializer): 9 | data_file_name = "locations.yml" 10 | 11 | def load_data(self): 12 | locations = self.load_yaml() 13 | if locations is None: 14 | return 15 | for params in locations: 16 | tags = params.pop("tags", None) 17 | 18 | for assoc, details in OPTIONAL_ASSOCS.items(): 19 | if assoc in params: 20 | model, field = details 21 | query = {field: params.pop(assoc)} 22 | params[assoc] = model.objects.get(**query) 23 | 24 | matching_params, defaults = self.split_params(params) 25 | location, created = Location.objects.get_or_create(**matching_params, defaults=defaults) 26 | 27 | if created: 28 | print("🎨 Created location", location.name) 29 | 30 | self.set_tags(location, tags) 31 | 32 | 33 | register_initializer("locations", LocationInitializer) 34 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/device_roles.py: -------------------------------------------------------------------------------- 1 | from dcim.models import DeviceRole 2 | from netbox.choices import ColorChoices 3 | 4 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 5 | 6 | 7 | class DeviceRoleInitializer(BaseInitializer): 8 | data_file_name = "device_roles.yml" 9 | 10 | def load_data(self): 11 | device_roles = self.load_yaml() 12 | if device_roles is None: 13 | return 14 | for params in device_roles: 15 | tags = params.pop("tags", None) 16 | 17 | if "color" in params: 18 | color = params.pop("color") 19 | 20 | for color_tpl in ColorChoices: 21 | if color in color_tpl: 22 | params["color"] = color_tpl[0] 23 | 24 | matching_params, defaults = self.split_params(params) 25 | device_role, created = DeviceRole.objects.get_or_create( 26 | **matching_params, defaults=defaults 27 | ) 28 | 29 | if created: 30 | print("🎨 Created device role", device_role.name) 31 | 32 | self.set_tags(device_role, tags) 33 | 34 | 35 | register_initializer("device_roles", DeviceRoleInitializer) 36 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/site_groups.py: -------------------------------------------------------------------------------- 1 | from dcim.models import SiteGroup 2 | 3 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 4 | 5 | OPTIONAL_ASSOCS = {"parent": (SiteGroup, "name")} 6 | 7 | 8 | class SiteGroupInitializer(BaseInitializer): 9 | data_file_name = "site_groups.yml" 10 | 11 | def load_data(self): 12 | site_groups = self.load_yaml() 13 | if site_groups is None: 14 | return 15 | for params in site_groups: 16 | tags = params.pop("tags", None) 17 | 18 | for assoc, details in OPTIONAL_ASSOCS.items(): 19 | if assoc in params: 20 | model, field = details 21 | query = {field: params.pop(assoc)} 22 | 23 | params[assoc] = model.objects.get(**query) 24 | 25 | matching_params, defaults = self.split_params(params) 26 | site_group, created = SiteGroup.objects.get_or_create( 27 | **matching_params, defaults=defaults 28 | ) 29 | 30 | if created: 31 | print("🌐 Created Site Group", site_group.name) 32 | 33 | self.set_tags(site_group, tags) 34 | 35 | 36 | register_initializer("site_groups", SiteGroupInitializer) 37 | -------------------------------------------------------------------------------- /test/docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | netbox: 4 | depends_on: 5 | - postgres 6 | - redis 7 | - redis-cache 8 | user: "unit:root" 9 | env_file: env/netbox.env 10 | volumes: 11 | - ./initializer-data:/etc/netbox/initializer-data:z,ro 12 | build: 13 | context: .. 14 | dockerfile: test/Dockerfile 15 | # postgres 16 | postgres: 17 | image: docker.io/postgres:17-alpine 18 | env_file: env/postgres.env 19 | volumes: 20 | - netbox-postgres-data:/var/lib/postgresql/data 21 | 22 | # redis 23 | redis: 24 | image: docker.io/valkey/valkey:8.1-alpine 25 | command: 26 | - sh 27 | - -c # this is to evaluate the $REDIS_PASSWORD from the env 28 | - redis-server --appendonly yes --requirepass $$REDIS_PASSWORD ## $$ because of docker-compose 29 | env_file: env/redis.env 30 | volumes: 31 | - netbox-redis-data:/data 32 | redis-cache: 33 | image: docker.io/valkey/valkey:8.1-alpine 34 | command: 35 | - sh 36 | - -c # this is to evaluate the $REDIS_PASSWORD from the env 37 | - redis-server --requirepass $$REDIS_PASSWORD ## $$ because of docker-compose 38 | env_file: env/redis-cache.env 39 | 40 | volumes: 41 | netbox-postgres-data: 42 | driver: local 43 | netbox-redis-data: 44 | driver: local 45 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/tenants.py: -------------------------------------------------------------------------------- 1 | from tenancy.models import Tenant, TenantGroup 2 | 3 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 4 | 5 | OPTIONAL_ASSOCS = {"group": (TenantGroup, "name")} 6 | 7 | 8 | class TenantInitializer(BaseInitializer): 9 | data_file_name = "tenants.yml" 10 | 11 | def load_data(self): 12 | tenants = self.load_yaml() 13 | if tenants is None: 14 | return 15 | for params in tenants: 16 | custom_field_data = self.pop_custom_fields(params) 17 | tags = params.pop("tags", None) 18 | 19 | for assoc, details in OPTIONAL_ASSOCS.items(): 20 | if assoc in params: 21 | model, field = details 22 | query = {field: params.pop(assoc)} 23 | 24 | params[assoc] = model.objects.get(**query) 25 | 26 | matching_params, defaults = self.split_params(params) 27 | tenant, created = Tenant.objects.get_or_create(**matching_params, defaults=defaults) 28 | 29 | if created: 30 | print("👩‍💻 Created Tenant", tenant.name) 31 | 32 | self.set_custom_fields_values(tenant, custom_field_data) 33 | self.set_tags(tenant, tags) 34 | 35 | 36 | register_initializer("tenants", TenantInitializer) 37 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/platforms.py: -------------------------------------------------------------------------------- 1 | from dcim.models import Manufacturer, Platform 2 | from extras.models import ConfigTemplate 3 | 4 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 5 | 6 | OPTIONAL_ASSOCS = { 7 | "manufacturer": (Manufacturer, "name"), 8 | "config_template": (ConfigTemplate, "name"), 9 | } 10 | 11 | 12 | class PlatformInitializer(BaseInitializer): 13 | data_file_name = "platforms.yml" 14 | 15 | def load_data(self): 16 | platforms = self.load_yaml() 17 | if platforms is None: 18 | return 19 | for params in platforms: 20 | tags = params.pop("tags", None) 21 | 22 | for assoc, details in OPTIONAL_ASSOCS.items(): 23 | if assoc in params: 24 | model, field = details 25 | query = {field: params.pop(assoc)} 26 | 27 | params[assoc] = model.objects.get(**query) 28 | 29 | matching_params, defaults = self.split_params(params) 30 | platform, created = Platform.objects.get_or_create(**matching_params, defaults=defaults) 31 | 32 | if created: 33 | print("💾 Created platform", platform.name) 34 | 35 | self.set_tags(platform, tags) 36 | 37 | 38 | register_initializer("platforms", PlatformInitializer) 39 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/vrfs.py: -------------------------------------------------------------------------------- 1 | from ipam.models import VRF 2 | from tenancy.models import Tenant 3 | 4 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 5 | 6 | MATCH_PARAMS = ["name", "rd"] 7 | OPTIONAL_ASSOCS = {"tenant": (Tenant, "name")} 8 | 9 | 10 | class VRFInitializer(BaseInitializer): 11 | data_file_name = "vrfs.yml" 12 | 13 | def load_data(self): 14 | vrfs = self.load_yaml() 15 | if vrfs is None: 16 | return 17 | for params in vrfs: 18 | custom_field_data = self.pop_custom_fields(params) 19 | tags = params.pop("tags", None) 20 | 21 | for assoc, details in OPTIONAL_ASSOCS.items(): 22 | if assoc in params: 23 | model, field = details 24 | query = {field: params.pop(assoc)} 25 | 26 | params[assoc] = model.objects.get(**query) 27 | 28 | matching_params, defaults = self.split_params(params, MATCH_PARAMS) 29 | vrf, created = VRF.objects.get_or_create(**matching_params, defaults=defaults) 30 | 31 | if created: 32 | print("📦 Created VRF", vrf.name) 33 | 34 | self.set_custom_fields_values(vrf, custom_field_data) 35 | self.set_tags(vrf, tags) 36 | 37 | 38 | register_initializer("vrfs", VRFInitializer) 39 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/object_permissions.yml: -------------------------------------------------------------------------------- 1 | # all.ro: 2 | # actions: 3 | # - view 4 | # description: 'Read Only for All Objects' 5 | # enabled: true 6 | # groups: 7 | # - applications 8 | # - readers 9 | # object_types: all 10 | # users: 11 | # - jdoe 12 | # all.rw: 13 | # actions: 14 | # - add 15 | # - change 16 | # - delete 17 | # - view 18 | # description: 'Read/Write for All Objects' 19 | # enabled: true 20 | # groups: 21 | # - writers 22 | # object_types: all 23 | # network_team.rw: 24 | # actions: 25 | # - add 26 | # - change 27 | # - delete 28 | # - view 29 | # description: "Network Team Permissions" 30 | # enabled: true 31 | # object_types: 32 | # circuits: 33 | # - circuit 34 | # - circuittermination 35 | # - circuittype 36 | # - provider 37 | # dcim: all 38 | # ipam: 39 | # - aggregate 40 | # - ipaddress 41 | # - prefix 42 | # - rir 43 | # - role 44 | # - routetarget 45 | # - service 46 | # - vlan 47 | # - vlangroup 48 | # - vrf 49 | # vips.change: 50 | # actions: 51 | # - change 52 | # description: "Update VIP object permission" 53 | # enabled: true 54 | # object_types: 55 | # ipam: 56 | # - ipaddress 57 | # groups: 58 | # - devops 59 | # constraints: 60 | # role: vip 61 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "netbox-initializers" 3 | authors = [{ name = "Tobias Genannt", email = "tobias.genannt@gmail.com" }] 4 | classifiers = [ 5 | "Framework :: Django", 6 | "Environment :: Plugins", 7 | "Topic :: System :: Networking", 8 | "Topic :: System :: Systems Administration", 9 | ] 10 | description = "Load initial data into Netbox" 11 | readme = "README.md" 12 | license = "Apache-2.0" 13 | dynamic = ["version"] 14 | 15 | requires-python = ">=3.10" 16 | dependencies = ["ruamel-yaml>=0.18.10"] 17 | 18 | [project.urls] 19 | repository = "https://github.com/tobiasge/netbox-initializers" 20 | issues = "https://github.com/tobiasge/netbox-initializers/issues" 21 | releasenotes = "https://github.com/tobiasge/netbox-initializers/releases" 22 | 23 | [build-system] 24 | requires = ["hatchling"] 25 | build-backend = "hatchling.build" 26 | 27 | [tool.hatch.version] 28 | path = "src/netbox_initializers/version.py" 29 | 30 | [tool.uv] 31 | dev-dependencies = ["ruff==0.12"] 32 | 33 | [tool.ruff] 34 | line-length = 100 35 | target-version = "py311" 36 | 37 | [tool.ruff.lint] 38 | extend-select = ["I", "PL", "W191", "W291", "W292", "W293"] 39 | ignore = ["PLR0912", "PLR0915"] 40 | 41 | [tool.ruff.lint.isort] 42 | section-order = [ 43 | "future", 44 | "standard-library", 45 | "third-party", 46 | "first-party", 47 | "local-folder", 48 | ] 49 | 50 | [tool.ruff.format] 51 | docstring-code-format = true 52 | docstring-code-line-length = "dynamic" 53 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/contact_groups.py: -------------------------------------------------------------------------------- 1 | from tenancy.models import ContactGroup 2 | 3 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 4 | 5 | OPTIONAL_ASSOCS = {"parent": (ContactGroup, "name")} 6 | 7 | 8 | class ContactGroupInitializer(BaseInitializer): 9 | data_file_name = "contact_groups.yml" 10 | 11 | def load_data(self): 12 | contact_groups = self.load_yaml() 13 | if contact_groups is None: 14 | return 15 | for params in contact_groups: 16 | custom_field_data = self.pop_custom_fields(params) 17 | tags = params.pop("tags", None) 18 | 19 | for assoc, details in OPTIONAL_ASSOCS.items(): 20 | if assoc in params: 21 | model, field = details 22 | query = {field: params.pop(assoc)} 23 | 24 | params[assoc] = model.objects.get(**query) 25 | 26 | matching_params, defaults = self.split_params(params) 27 | contact_group, created = ContactGroup.objects.get_or_create( 28 | **matching_params, defaults=defaults 29 | ) 30 | 31 | if created: 32 | print("🔳 Created Contact Group", contact_group.name) 33 | 34 | self.set_custom_fields_values(contact_group, custom_field_data) 35 | self.set_tags(contact_group, tags) 36 | 37 | 38 | register_initializer("contact_groups", ContactGroupInitializer) 39 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/route_targets.py: -------------------------------------------------------------------------------- 1 | from ipam.models import RouteTarget 2 | from tenancy.models import Tenant 3 | 4 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 5 | 6 | OPTIONAL_ASSOCS = {"tenant": (Tenant, "name")} 7 | 8 | 9 | class RouteTargetInitializer(BaseInitializer): 10 | data_file_name = "route_targets.yml" 11 | 12 | def load_data(self): 13 | route_targets = self.load_yaml() 14 | if route_targets is None: 15 | return 16 | for params in route_targets: 17 | custom_field_data = self.pop_custom_fields(params) 18 | tags = params.pop("tags", None) 19 | 20 | for assoc, details in OPTIONAL_ASSOCS.items(): 21 | if assoc in params: 22 | model, field = details 23 | query = {field: params.pop(assoc)} 24 | 25 | params[assoc] = model.objects.get(**query) 26 | 27 | matching_params, defaults = self.split_params(params) 28 | route_target, created = RouteTarget.objects.get_or_create( 29 | **matching_params, defaults=defaults 30 | ) 31 | 32 | if created: 33 | print("🎯 Created Route Target", route_target.name) 34 | 35 | self.set_custom_fields_values(route_target, custom_field_data) 36 | self.set_tags(route_target, tags) 37 | 38 | 39 | register_initializer("route_targets", RouteTargetInitializer) 40 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/devices.yml: -------------------------------------------------------------------------------- 1 | ## Possible Choices: 2 | ## face: 3 | ## - front 4 | ## - rear 5 | ## status: 6 | ## - offline 7 | ## - active 8 | ## - planned 9 | ## - staged 10 | ## - failed 11 | ## - inventory 12 | ## - decommissioning 13 | ## 14 | ## Examples: 15 | 16 | # - name: server01 17 | # role: server 18 | # device_type: Other 19 | # site: AMS 1 20 | # rack: rack-01 21 | # face: front 22 | # position: 1 23 | # custom_field_data: 24 | # text_field: Description 25 | # - name: server02 26 | # role: server 27 | # device_type: Other 28 | # site: AMS 2 29 | # rack: rack-02 30 | # face: front 31 | # position: 2 32 | # primary_ip4: 10.1.1.2/24 33 | # primary_ip4_vrf: vrf1 34 | # primary_ip6: 2001:db8:a000:1::2/64 35 | # primary_ip6_vrf: vrf1 36 | # custom_field_data: 37 | # text_field: Description 38 | # - name: server03 39 | # role: server 40 | # device_type: Other 41 | # site: SING 1 42 | # rack: rack-03 43 | # face: front 44 | # position: 3 45 | # custom_field_data: 46 | # text_field: Description 47 | # - name: server04 48 | # role: server 49 | # device_type: Other 50 | # site: SING 1 51 | # location: cage 101 52 | # face: front 53 | # position: 3 54 | # config_template: configtemplate1 55 | # custom_field_data: 56 | # text_field: Description 57 | # 58 | ## Templated device 59 | # - name: gns3-tor 60 | # role: switch 61 | # device_type: TOR-8P 62 | # site: SING 1 63 | # rack: rack-03 64 | -------------------------------------------------------------------------------- /docs/dev/Release.md: -------------------------------------------------------------------------------- 1 | # Release management 2 | 3 | The version numbers will follow those of Netbox. A given release of netbox-initializers is only compatible with a Netbox release with a matching `Major.Minor` version. The patch release number will be used for bugfixes in the plugin. 4 | 5 | ## Prepare a release 6 | 7 | Please follow these steps to produce a release 8 | 9 | ### Checkout correct branch 10 | 11 | Checkout the branch for which the release is to be build. If no branch exists for the new release one must be created. The name must correspond to the Netbox version in the format "netbox/MAJOR.MINOR". 12 | 13 | ### Set version number 14 | 15 | For patch releases the version number in `src/netbox_initializers/version.py` needs to be updated. If the release is for a new Netbox version additional changes need to be made in `README.md` and `Dockerfile` (for tests). 16 | 17 | ### Build the release automatically 18 | 19 | After changing the version numbers and committing them create a new release with the GitHub Web UI. Configure the release to create a new tag with the name `vX.Y.Z`. 20 | 21 | ### Build the release manually 22 | 23 | #### Build the packages 24 | 25 | Install the needed Python packages for the build: 26 | 27 | ```bash 28 | pip install --upgrade uv 29 | ``` 30 | 31 | Then run the build for the wheel and source distributions: 32 | 33 | ```bash 34 | uvx --from build pyproject-build --installer uv 35 | ``` 36 | 37 | #### Upload packages to PyPi 38 | 39 | ```bash 40 | uvx twine upload dist/* 41 | ``` 42 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/asns.py: -------------------------------------------------------------------------------- 1 | from ipam.models import ASN, RIR 2 | from tenancy.models import Tenant 3 | 4 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 5 | 6 | MATCH_PARAMS = ["asn", "rir"] 7 | REQUIRED_ASSOCS = {"rir": (RIR, "name")} 8 | OPTIONAL_ASSOCS = {"tenant": (Tenant, "name")} 9 | 10 | 11 | class ASNInitializer(BaseInitializer): 12 | data_file_name = "asns.yml" 13 | 14 | def load_data(self): 15 | asns = self.load_yaml() 16 | if asns is None: 17 | return 18 | for params in asns: 19 | tags = params.pop("tags", None) 20 | for assoc, details in REQUIRED_ASSOCS.items(): 21 | model, field = details 22 | query = {field: params.pop(assoc)} 23 | 24 | params[assoc] = model.objects.get(**query) 25 | 26 | for assoc, details in OPTIONAL_ASSOCS.items(): 27 | if assoc in params: 28 | model, field = details 29 | query = {field: params.pop(assoc)} 30 | 31 | params[assoc] = model.objects.get(**query) 32 | 33 | matching_params, defaults = self.split_params(params, MATCH_PARAMS) 34 | asn, created = ASN.objects.get_or_create(**matching_params, defaults=defaults) 35 | 36 | if created: 37 | print(f"🔡 Created ASN {asn.asn}") 38 | 39 | self.set_tags(asn, tags) 40 | 41 | 42 | register_initializer("asns", ASNInitializer) 43 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/config_templates.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.models import ContentType 2 | from extras.models import ConfigTemplate 3 | 4 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 5 | 6 | MATCH_PARAMS = ["name", "description", "template_code", "environment_params"] 7 | 8 | 9 | def get_content_type_id(hook_name, content_type): 10 | try: 11 | return ContentType.objects.get(model=content_type).id 12 | except ContentType.DoesNotExist as ex: 13 | print("⚠️ Webhook '{0}': The object_type '{1}' is unknown.".format(hook_name, content_type)) 14 | raise ex 15 | 16 | 17 | class ConfigTemplateInitializer(BaseInitializer): 18 | data_file_name = "config_templates.yml" 19 | 20 | def load_data(self): 21 | config_templates = self.load_yaml() 22 | if config_templates is None: 23 | return 24 | for template in config_templates: 25 | tags = template.pop("tags", None) 26 | matching_params, defaults = self.split_params(template) 27 | config_template, created = ConfigTemplate.objects.get_or_create( 28 | **matching_params, defaults=defaults 29 | ) 30 | 31 | if created: 32 | config_template.save() 33 | print("🪝 Created Config Template {0}".format(config_template.name)) 34 | self.set_tags(config_template, tags) 35 | 36 | 37 | register_initializer("config_templates", ConfigTemplateInitializer) 38 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/providers.py: -------------------------------------------------------------------------------- 1 | from circuits.models import Provider 2 | from ipam.models import ASN 3 | 4 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 5 | 6 | 7 | class ProviderInitializer(BaseInitializer): 8 | data_file_name = "providers.yml" 9 | 10 | def load_data(self): 11 | providers = self.load_yaml() 12 | if providers is None: 13 | return 14 | for params in providers: 15 | custom_field_data = self.pop_custom_fields(params) 16 | tags = params.pop("tags", None) 17 | 18 | asn_number = params.pop("asn") 19 | asn = ASN.objects.filter(asn=asn_number).first() 20 | if asn is None: 21 | print( 22 | "⚠️ Unable to create Provider '{0}': The ASN '{1}' is unknown".format( 23 | params.get("name"), asn_number 24 | ) 25 | ) 26 | continue 27 | 28 | matching_params, defaults = self.split_params(params) 29 | provider, created = Provider.objects.get_or_create(**matching_params, defaults=defaults) 30 | 31 | if created: 32 | provider.asns.add(asn) 33 | provider.save() 34 | print("📡 Created provider", provider.name) 35 | 36 | self.set_custom_fields_values(provider, custom_field_data) 37 | self.set_tags(provider, tags) 38 | 39 | 40 | register_initializer("providers", ProviderInitializer) 41 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/tags.py: -------------------------------------------------------------------------------- 1 | from core.models import ObjectType 2 | from extras.models import Tag 3 | from netbox.choices import ColorChoices 4 | 5 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 6 | 7 | 8 | class TagInitializer(BaseInitializer): 9 | data_file_name = "tags.yml" 10 | 11 | def load_data(self): 12 | tags = self.load_yaml() 13 | if tags is None: 14 | return 15 | for params in tags: 16 | if "color" in params: 17 | color = params.pop("color") 18 | 19 | for color_tpl in ColorChoices: 20 | if color in color_tpl: 21 | params["color"] = color_tpl[0] 22 | 23 | object_types = params.pop("object_types", None) 24 | matching_params, defaults = self.split_params(params) 25 | tag, created = Tag.objects.get_or_create(**matching_params, defaults=defaults) 26 | 27 | if created: 28 | print("🎨 Created Tag", tag.name) 29 | 30 | if object_types: 31 | for ot in object_types: 32 | ct = ObjectType.objects.get( 33 | app_label=ot["app"], 34 | model=ot["model"], 35 | ) 36 | tag.object_types.add(ct) 37 | print(f"🎨 Restricted Tag {tag.name} to {ot['app']}.{ot['model']}") 38 | 39 | 40 | register_initializer("tags", TagInitializer) 41 | -------------------------------------------------------------------------------- /src/netbox_initializers/management/commands/load_initializer_data.py: -------------------------------------------------------------------------------- 1 | import os 2 | import traceback 3 | 4 | from django.core.management.base import BaseCommand, CommandError 5 | 6 | from netbox_initializers.initializers.base import INITIALIZER_ORDER, INITIALIZER_REGISTRY 7 | 8 | 9 | class Command(BaseCommand): 10 | help = "Load data from YAML files into Netbox" 11 | requires_migrations_checks = True 12 | 13 | def add_arguments(self, parser): 14 | parser.add_argument( 15 | "--path", action="store", dest="path", help="Path of the initial data YAMLs" 16 | ) 17 | 18 | def handle(self, *args, **options): 19 | target_path = options["path"] 20 | if not target_path: 21 | raise CommandError("Path cannot be empty.") 22 | 23 | if not os.path.isdir(target_path): 24 | raise CommandError("Path must be a directory.") 25 | 26 | for initializer_name in INITIALIZER_ORDER: 27 | if initializer_name not in INITIALIZER_REGISTRY: 28 | self.stderr.write( 29 | self.style.ERROR(f"Initializer for {initializer_name} not found!") 30 | ) 31 | continue 32 | 33 | initializer = INITIALIZER_REGISTRY[initializer_name] 34 | initializer_instance = initializer(target_path) 35 | try: 36 | initializer_instance.load_data() 37 | except Exception as e: 38 | traceback.print_exception(e) 39 | raise CommandError(f"{initializer.__name__} failed.") from e 40 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/rack_types.yml: -------------------------------------------------------------------------------- 1 | # # Examples: 2 | # - model: Rack Type 1 3 | # manufacturer: manufacturer-1 4 | # slug: rack-type-1 5 | # form_factor: 4-post-cabinet 6 | # width: 19 7 | # u_height: 47 8 | # custom_field_data: 9 | # text_field: Description 10 | # starting_unit: 2 11 | # desc_units: true 12 | # outer_width: 600 13 | # outer_depth: 1000 14 | # outer_unit: mm 15 | # mounting_depth: 800 16 | # weight: 100.3 17 | # weight_unit: kg 18 | # description: Description for Rack Type 1 19 | # comments: Comments for Rack Type 1 20 | # - model: Rack Type 2 21 | # manufacturer: manufacturer-2 22 | # slug: rack-type-2 23 | # form_factor: 2-post-frame 24 | # width: 23 25 | # u_height: 24 26 | # custom_field_data: 27 | # text_field: Description 28 | # starting_unit: 1 29 | # desc_units: false 30 | # outer_width: 800 31 | # outer_depth: 1200 32 | # outer_unit: mm 33 | # mounting_depth: 1000 34 | # weight: 80.5 35 | # weight_unit: kg 36 | # description: Description for Rack Type 2 37 | # comments: Comments for Rack Type 2 38 | # - model: Rack Type 3 39 | # manufacturer: no-name 40 | # slug: rack-type-3 41 | # form_factor: wall-mount 42 | # width: 10 43 | # u_height: 12 44 | # custom_field_data: 45 | # text_field: Description 46 | # starting_unit: 1 47 | # desc_units: true 48 | # outer_width: 500 49 | # outer_depth: 300 50 | # outer_unit: mm 51 | # mounting_depth: 250 52 | # weight: 30.2 53 | # weight_unit: kg 54 | # description: Description for Rack Type 3 55 | # comments: Comments for Rack Type 3 56 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/vlans.py: -------------------------------------------------------------------------------- 1 | from dcim.models import Site 2 | from ipam.models import VLAN, Role, VLANGroup 3 | from tenancy.models import Tenant, TenantGroup 4 | 5 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 6 | 7 | MATCH_PARAMS = ["name", "vid"] 8 | OPTIONAL_ASSOCS = { 9 | "site": (Site, "name"), 10 | "tenant": (Tenant, "name"), 11 | "tenant_group": (TenantGroup, "name"), 12 | "group": (VLANGroup, "name"), 13 | "role": (Role, "name"), 14 | } 15 | 16 | 17 | class VLANInitializer(BaseInitializer): 18 | data_file_name = "vlans.yml" 19 | 20 | def load_data(self): 21 | vlans = self.load_yaml() 22 | if vlans is None: 23 | return 24 | for params in vlans: 25 | custom_field_data = self.pop_custom_fields(params) 26 | tags = params.pop("tags", None) 27 | 28 | for assoc, details in OPTIONAL_ASSOCS.items(): 29 | if assoc in params: 30 | model, field = details 31 | query = {field: params.pop(assoc)} 32 | 33 | params[assoc] = model.objects.get(**query) 34 | 35 | matching_params, defaults = self.split_params(params, MATCH_PARAMS) 36 | vlan, created = VLAN.objects.get_or_create(**matching_params, defaults=defaults) 37 | 38 | if created: 39 | print("🏠 Created VLAN", vlan.name) 40 | 41 | self.set_custom_fields_values(vlan, custom_field_data) 42 | self.set_tags(vlan, tags) 43 | 44 | 45 | register_initializer("vlans", VLANInitializer) 46 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/custom_links.py: -------------------------------------------------------------------------------- 1 | from core.models import ObjectType 2 | from extras.models import CustomLink 3 | 4 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 5 | 6 | 7 | def get_content_type(content_type): 8 | try: 9 | return ObjectType.objects.get(model=content_type) 10 | except ObjectType.DoesNotExist: 11 | pass 12 | return None 13 | 14 | 15 | class CustomLinkInitializer(BaseInitializer): 16 | data_file_name = "custom_links.yml" 17 | 18 | def load_data(self): 19 | custom_links = self.load_yaml() 20 | if custom_links is None: 21 | return 22 | for link in custom_links: 23 | content_type_name = link.pop("content_type") 24 | content_type = get_content_type(content_type_name) 25 | if content_type is None: 26 | print( 27 | "⚠️ Unable to create Custom Link '{0}': The content_type '{1}' is unknown".format( 28 | link.get("name"), content_type 29 | ) 30 | ) 31 | continue 32 | 33 | matching_params, defaults = self.split_params(link) 34 | custom_link, created = CustomLink.objects.get_or_create( 35 | **matching_params, defaults=defaults 36 | ) 37 | 38 | if created: 39 | custom_link.object_types.add(content_type) 40 | custom_link.save() 41 | print("🔗 Created Custom Link '{0}'".format(custom_link.name)) 42 | 43 | 44 | register_initializer("custom_links", CustomLinkInitializer) 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Netbox Initializers Plugin 2 | 3 | Load data from YAML files into Netbox 4 | 5 | ## Installation 6 | 7 | First activate your virtual environment where Netbox is installed, the install the plugin version correspondig to your Netbox version. 8 | 9 | ```bash 10 | pip install "netbox-initializers==4.4.*" 11 | ``` 12 | 13 | Then you need to add the plugin to the `PLUGINS` array in the Netbox configuration. 14 | 15 | ```python 16 | PLUGINS = [ 17 | 'netbox_initializers', 18 | ] 19 | ``` 20 | 21 | ## Getting started 22 | 23 | At first you need to start with writing the YAML files that contain the initial data. To make that easier the plugin includes example files for all supported initializers. To access those examples you can copy them into a directory of your choosing and edit them there. To copy the files run the following command (in your Netbox directory): 24 | 25 | ```bash 26 | ./manage.py copy_initializers_examples --path /path/for/example/files 27 | ``` 28 | 29 | After you filled in the data you want to import, the import can be started with this command: 30 | 31 | ```bash 32 | ./manage.py load_initializer_data --path /path/for/example/files 33 | ``` 34 | 35 | ## Netbox Docker image 36 | 37 | The initializers where a part of the Docker image and where then extracted into a Netbox plugin. This was done to split the release cycle of the initializers and the image. 38 | To use the new plugin in a the Netbox Docker image, it musst be installad into the image. To this, the following example can be used as a starting point: 39 | 40 | ```dockerfile 41 | FROM netboxcommunity/netbox:v4.4 42 | RUN /opt/netbox/venv/bin/pip install "netbox-initializers==4.4.*" 43 | ``` 44 | -------------------------------------------------------------------------------- /test/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # shellcheck disable=SC1091 4 | source ./gh-functions.sh 5 | 6 | # The docker compose command to use 7 | doco="docker compose --project-name netbox_initializer_test" 8 | 9 | INITIALIZERS_DIR="initializer-data" 10 | 11 | test_setup() { 12 | echo "🏗 Setup up test environment" 13 | if [ -d "${INITIALIZERS_DIR}" ]; then 14 | rm -rf "${INITIALIZERS_DIR}" 15 | fi 16 | 17 | mkdir "${INITIALIZERS_DIR}" 18 | ( 19 | cd ../src/netbox_initializers/initializers/yaml/ || exit 20 | for script in *.yml; do 21 | sed -E 's/^# //' "${script}" >"../../../../test/${INITIALIZERS_DIR}/${script}" 22 | done 23 | ) 24 | $doco build --no-cache || exit 1 25 | $doco run --rm netbox /opt/netbox/docker-entrypoint.sh ./manage.py check || exit 1 26 | } 27 | 28 | test_cleanup() { 29 | gh_echo "::group::Clean test environment" 30 | echo "💣 Cleaning Up" 31 | if [ "$KEEP_VOLUMES" == "true" ]; then 32 | $doco down 33 | else 34 | $doco down -v 35 | fi 36 | 37 | if [ -d "${INITIALIZERS_DIR}" ]; then 38 | rm -rf "${INITIALIZERS_DIR}" 39 | fi 40 | gh_echo "::endgroup::" 41 | } 42 | 43 | test_initializers() { 44 | echo "🏭 Testing Initializers" 45 | $doco run --rm netbox /opt/netbox/docker-entrypoint.sh ./manage.py load_initializer_data --path /etc/netbox/initializer-data || exit 1 46 | } 47 | 48 | echo "🐳🐳🐳 Start testing" 49 | 50 | # Make sure the cleanup script is executed 51 | trap test_cleanup EXIT ERR 52 | 53 | gh_echo "::group::Setup test environment" 54 | test_setup 55 | gh_echo "::endgroup::" 56 | 57 | gh_echo "::group::Initializer tests" 58 | test_initializers 59 | gh_echo "::endgroup::" 60 | 61 | echo "🐳🐳🐳 Done testing" 62 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/services.py: -------------------------------------------------------------------------------- 1 | from ipam.models import Service 2 | from django.contrib.contenttypes.models import ContentType 3 | 4 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 5 | 6 | MATCH_PARAMS = ["name", "parent_object_type", "parent_object_id"] 7 | 8 | 9 | class ServiceInitializer(BaseInitializer): 10 | data_file_name = "services.yml" 11 | 12 | def load_data(self): 13 | services = self.load_yaml() 14 | if services is None: 15 | return 16 | for params in services: 17 | tags = params.pop("tags", None) 18 | 19 | # Get model from Contenttype 20 | scope_type = params.pop("parent_type", None) 21 | if not scope_type: 22 | print( 23 | f"Services '{params['name']}': parent_type is missing from Services" 24 | ) 25 | app_label, model = str(scope_type).split(".") 26 | parent_model = ContentType.objects.get(app_label=app_label, model=model).model_class() 27 | parent = parent_model.objects.get(name=params.pop("parent_name")) 28 | 29 | params["parent_object_type"] = ContentType.objects.get_for_model(parent) 30 | params["parent_object_id"] = parent.id 31 | 32 | matching_params, defaults = self.split_params(params, MATCH_PARAMS) 33 | service, created = Service.objects.get_or_create(**matching_params, defaults=defaults) 34 | 35 | if created: 36 | print("🧰 Created Service", service.name) 37 | 38 | self.set_tags(service, tags) 39 | 40 | 41 | register_initializer("services", ServiceInitializer) 42 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/circuits.py: -------------------------------------------------------------------------------- 1 | from circuits.models import Circuit, CircuitType, Provider 2 | from tenancy.models import Tenant 3 | 4 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 5 | 6 | MATCH_PARAMS = ["cid", "provider", "type"] 7 | REQUIRED_ASSOCS = {"provider": (Provider, "name"), "type": (CircuitType, "name")} 8 | OPTIONAL_ASSOCS = {"tenant": (Tenant, "name")} 9 | 10 | 11 | class CircuitInitializer(BaseInitializer): 12 | data_file_name = "circuits.yml" 13 | 14 | def load_data(self): 15 | circuits = self.load_yaml() 16 | if circuits is None: 17 | return 18 | for params in circuits: 19 | custom_field_data = self.pop_custom_fields(params) 20 | tags = params.pop("tags", None) 21 | 22 | for assoc, details in REQUIRED_ASSOCS.items(): 23 | model, field = details 24 | query = {field: params.pop(assoc)} 25 | 26 | params[assoc] = model.objects.get(**query) 27 | 28 | for assoc, details in OPTIONAL_ASSOCS.items(): 29 | if assoc in params: 30 | model, field = details 31 | query = {field: params.pop(assoc)} 32 | 33 | params[assoc] = model.objects.get(**query) 34 | 35 | matching_params, defaults = self.split_params(params, MATCH_PARAMS) 36 | circuit, created = Circuit.objects.get_or_create(**matching_params, defaults=defaults) 37 | 38 | if created: 39 | print("⚡ Created Circuit", circuit.cid) 40 | 41 | self.set_custom_fields_values(circuit, custom_field_data) 42 | self.set_tags(circuit, tags) 43 | 44 | 45 | register_initializer("circuits", CircuitInitializer) 46 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/power_feeds.py: -------------------------------------------------------------------------------- 1 | from dcim.models import PowerFeed, PowerPanel, Rack 2 | 3 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 4 | 5 | MATCH_PARAMS = ["name", "power_panel"] 6 | OPTIONAL_ASSOCS = {"rack": (Rack, "name")} 7 | REQUIRED_ASSOCS = {"power_panel": (PowerPanel, "name")} 8 | 9 | 10 | class PowerFeedInitializer(BaseInitializer): 11 | data_file_name = "power_feeds.yml" 12 | 13 | def load_data(self): 14 | power_feeds = self.load_yaml() 15 | if power_feeds is None: 16 | return 17 | for params in power_feeds: 18 | custom_field_data = self.pop_custom_fields(params) 19 | tags = params.pop("tags", None) 20 | 21 | for assoc, details in REQUIRED_ASSOCS.items(): 22 | model, field = details 23 | query = {field: params.pop(assoc)} 24 | 25 | params[assoc] = model.objects.get(**query) 26 | 27 | for assoc, details in OPTIONAL_ASSOCS.items(): 28 | if assoc in params: 29 | model, field = details 30 | query = {field: params.pop(assoc)} 31 | 32 | params[assoc] = model.objects.get(**query) 33 | 34 | matching_params, defaults = self.split_params(params, MATCH_PARAMS) 35 | power_feed, created = PowerFeed.objects.get_or_create( 36 | **matching_params, defaults=defaults 37 | ) 38 | 39 | if created: 40 | print("⚡ Created Power Feed", power_feed.name) 41 | 42 | self.set_custom_fields_values(power_feed, custom_field_data) 43 | self.set_tags(power_feed, tags) 44 | 45 | 46 | register_initializer("power_feeds", PowerFeedInitializer) 47 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/contacts.py: -------------------------------------------------------------------------------- 1 | from tenancy.models import Contact, ContactGroup 2 | 3 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 4 | 5 | 6 | class ContactInitializer(BaseInitializer): 7 | data_file_name = "contacts.yml" 8 | 9 | def load_data(self): 10 | contacts = self.load_yaml() 11 | if contacts is None: 12 | return 13 | for params in contacts: 14 | custom_field_data = self.pop_custom_fields(params) 15 | tags = params.pop("tags", None) 16 | 17 | # Group foreign key on the Contact model is a now many-to-many groups field 18 | groups = params.pop("groups", None) # Extract the groups from params if they exist 19 | group_objects = [] 20 | if groups: 21 | for group_name in groups: # Iterate through the group names 22 | try: 23 | group_objects.append(ContactGroup.objects.get(name=group_name)) 24 | except ContactGroup.DoesNotExist: 25 | raise ValueError(f"ContactGroup with name '{group_name}' does not exist.") 26 | 27 | matching_params, defaults = self.split_params(params) 28 | contact, created = Contact.objects.get_or_create(**matching_params, defaults=defaults) 29 | 30 | if created: 31 | print("👩‍💻 Created Contact", contact.name) 32 | 33 | # Add the groups to the contact if any were found 34 | if group_objects: 35 | contact.groups.set(group_objects) 36 | 37 | self.set_custom_fields_values(contact, custom_field_data) 38 | self.set_tags(contact, tags) 39 | 40 | 41 | register_initializer("contacts", ContactInitializer) 42 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/power_panels.py: -------------------------------------------------------------------------------- 1 | from dcim.models import Location, PowerPanel, Site 2 | 3 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 4 | 5 | MATCH_PARAMS = ["name", "site"] 6 | REQUIRED_ASSOCS = {"site": (Site, "name")} 7 | OPTIONAL_ASSOCS = {"location": (Location, "name")} 8 | 9 | 10 | class PowerPanelInitializer(BaseInitializer): 11 | data_file_name = "power_panels.yml" 12 | 13 | def load_data(self): 14 | power_panels = self.load_yaml() 15 | if power_panels is None: 16 | return 17 | for params in power_panels: 18 | custom_field_data = self.pop_custom_fields(params) 19 | tags = params.pop("tags", None) 20 | 21 | for assoc, details in REQUIRED_ASSOCS.items(): 22 | model, field = details 23 | query = {field: params.pop(assoc)} 24 | 25 | params[assoc] = model.objects.get(**query) 26 | 27 | for assoc, details in OPTIONAL_ASSOCS.items(): 28 | if assoc in params: 29 | model, field = details 30 | query = {field: params.pop(assoc)} 31 | 32 | params[assoc] = model.objects.get(**query) 33 | 34 | matching_params, defaults = self.split_params(params, MATCH_PARAMS) 35 | power_panel, created = PowerPanel.objects.get_or_create( 36 | **matching_params, defaults=defaults 37 | ) 38 | 39 | if created: 40 | print("⚡ Created Power Panel", power_panel.site, power_panel.name) 41 | 42 | self.set_custom_fields_values(power_panel, custom_field_data) 43 | self.set_tags(power_panel, tags) 44 | 45 | 46 | register_initializer("power_panels", PowerPanelInitializer) 47 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/racks.py: -------------------------------------------------------------------------------- 1 | from dcim.models import Location, Rack, RackRole, RackType, Site 2 | from tenancy.models import Tenant 3 | 4 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 5 | 6 | MATCH_PARAMS = ["name", "site"] 7 | REQUIRED_ASSOCS = {"site": (Site, "name")} 8 | OPTIONAL_ASSOCS = { 9 | "role": (RackRole, "name"), 10 | "tenant": (Tenant, "name"), 11 | "location": (Location, "name"), 12 | "rack_type": (RackType, "slug"), 13 | } 14 | 15 | 16 | class RackInitializer(BaseInitializer): 17 | data_file_name = "racks.yml" 18 | 19 | def load_data(self): 20 | racks = self.load_yaml() 21 | if racks is None: 22 | return 23 | for params in racks: 24 | custom_field_data = self.pop_custom_fields(params) 25 | tags = params.pop("tags", None) 26 | 27 | for assoc, details in REQUIRED_ASSOCS.items(): 28 | model, field = details 29 | query = {field: params.pop(assoc)} 30 | 31 | params[assoc] = model.objects.get(**query) 32 | 33 | for assoc, details in OPTIONAL_ASSOCS.items(): 34 | if assoc in params: 35 | model, field = details 36 | query = {field: params.pop(assoc)} 37 | 38 | params[assoc] = model.objects.get(**query) 39 | 40 | matching_params, defaults = self.split_params(params, MATCH_PARAMS) 41 | rack, created = Rack.objects.get_or_create(**matching_params, defaults=defaults) 42 | 43 | if created: 44 | print("🔳 Created rack", rack.site, rack.name) 45 | 46 | self.set_custom_fields_values(rack, custom_field_data) 47 | self.set_tags(rack, tags) 48 | 49 | 50 | register_initializer("racks", RackInitializer) 51 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/clusters.py: -------------------------------------------------------------------------------- 1 | from dcim.models import Site 2 | from tenancy.models import Tenant 3 | from virtualization.models import Cluster, ClusterGroup, ClusterType 4 | 5 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 6 | 7 | MATCH_PARAMS = ["name", "type"] 8 | REQUIRED_ASSOCS = {"type": (ClusterType, "name")} 9 | OPTIONAL_ASSOCS = { 10 | "scope": (Site, "name"), 11 | "group": (ClusterGroup, "name"), 12 | "tenant": (Tenant, "name"), 13 | } 14 | 15 | 16 | class ClusterInitializer(BaseInitializer): 17 | data_file_name = "clusters.yml" 18 | 19 | def load_data(self): 20 | clusters = self.load_yaml() 21 | if clusters is None: 22 | return 23 | for params in clusters: 24 | custom_field_data = self.pop_custom_fields(params) 25 | tags = params.pop("tags", None) 26 | 27 | for assoc, details in REQUIRED_ASSOCS.items(): 28 | model, field = details 29 | query = {field: params.pop(assoc)} 30 | 31 | params[assoc] = model.objects.get(**query) 32 | 33 | for assoc, details in OPTIONAL_ASSOCS.items(): 34 | if assoc in params: 35 | model, field = details 36 | query = {field: params.pop(assoc)} 37 | 38 | params[assoc] = model.objects.get(**query) 39 | 40 | matching_params, defaults = self.split_params(params, MATCH_PARAMS) 41 | cluster, created = Cluster.objects.get_or_create(**matching_params, defaults=defaults) 42 | 43 | if created: 44 | print("🗄️ Created cluster", cluster.name) 45 | 46 | self.set_custom_fields_values(cluster, custom_field_data) 47 | self.set_tags(cluster, tags) 48 | 49 | 50 | register_initializer("clusters", ClusterInitializer) 51 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/aggregates.py: -------------------------------------------------------------------------------- 1 | from ipam.models import RIR, Aggregate 2 | from netaddr import IPNetwork 3 | from tenancy.models import Tenant 4 | 5 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 6 | 7 | MATCH_PARAMS = ["prefix", "rir"] 8 | REQUIRED_ASSOCS = {"rir": (RIR, "name")} 9 | OPTIONAL_ASSOCS = { 10 | "tenant": (Tenant, "name"), 11 | } 12 | 13 | 14 | class AggregateInitializer(BaseInitializer): 15 | data_file_name = "aggregates.yml" 16 | 17 | def load_data(self): 18 | aggregates = self.load_yaml() 19 | if aggregates is None: 20 | return 21 | for params in aggregates: 22 | custom_field_data = self.pop_custom_fields(params) 23 | tags = params.pop("tags", None) 24 | 25 | params["prefix"] = IPNetwork(params["prefix"]) 26 | 27 | for assoc, details in REQUIRED_ASSOCS.items(): 28 | model, field = details 29 | query = {field: params.pop(assoc)} 30 | 31 | params[assoc] = model.objects.get(**query) 32 | 33 | for assoc, details in OPTIONAL_ASSOCS.items(): 34 | if assoc in params: 35 | model, field = details 36 | query = {field: params.pop(assoc)} 37 | 38 | params[assoc] = model.objects.get(**query) 39 | 40 | matching_params, defaults = self.split_params(params, MATCH_PARAMS) 41 | aggregate, created = Aggregate.objects.get_or_create( 42 | **matching_params, defaults=defaults 43 | ) 44 | 45 | if created: 46 | print("🗞️ Created Aggregate", aggregate.prefix) 47 | 48 | self.set_custom_fields_values(aggregate, custom_field_data) 49 | self.set_tags(aggregate, tags) 50 | 51 | 52 | register_initializer("aggregates", AggregateInitializer) 53 | -------------------------------------------------------------------------------- /src/netbox_initializers/management/commands/copy_initializers_examples.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | 4 | from django.core.management.base import BaseCommand, CommandError 5 | 6 | import netbox_initializers.initializers 7 | 8 | 9 | class Command(BaseCommand): 10 | help = "Copy initializer example files to user specified directory" 11 | 12 | def add_arguments(self, parser): 13 | parser.add_argument( 14 | "--path", 15 | action="store", 16 | dest="path", 17 | help="Path where the examples should be placed", 18 | required=True, 19 | ) 20 | 21 | def handle(self, *args, **options): 22 | target_path = options["path"] 23 | if not target_path: 24 | raise CommandError("Path cannot be empty.") 25 | 26 | if not os.path.isdir(target_path): 27 | raise CommandError("Path must be a directory.") 28 | 29 | intializer_base_path = os.path.dirname(netbox_initializers.initializers.__file__) 30 | intializer_path = f"{intializer_base_path}/yaml" 31 | warnings = 0 32 | with os.scandir(intializer_path) as yaml_files: 33 | for file in yaml_files: 34 | if not file.name.endswith("yml"): 35 | continue 36 | dst_file = f"{target_path}/{file.name}" 37 | if os.path.isfile(dst_file): 38 | self.stdout.write( 39 | self.style.WARNING( 40 | f"Warning: Destination file exists for {file.name}. File will not be copied." 41 | ) 42 | ) 43 | warnings += 1 44 | continue 45 | shutil.copyfile(file, dst_file) 46 | self.stdout.write( 47 | self.style.SUCCESS( 48 | f"Copied initializer examples to '{target_path}' with {warnings} warnings." 49 | ) 50 | ) 51 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/device_types.yml: -------------------------------------------------------------------------------- 1 | # - model: Model 1 2 | # manufacturer: Manufacturer 1 3 | # slug: model-1 4 | # u_height: 2 5 | # custom_field_data: 6 | # text_field: Description 7 | # - model: Model 2 8 | # manufacturer: Manufacturer 1 9 | # slug: model-2 10 | # custom_field_data: 11 | # text_field: Description 12 | # - model: Model 3 13 | # manufacturer: Manufacturer 1 14 | # slug: model-3 15 | # is_full_depth: false 16 | # u_height: 0 17 | # custom_field_data: 18 | # text_field: Description 19 | # - model: TOR-8P 20 | # manufacturer: No Name 21 | # part_number: vlab-eos 22 | # slug: tor-8p 23 | # interfaces: 24 | # - name: Ethernet1 25 | # type: 1000base-t 26 | # description: UPLINK 27 | # - model: Other 28 | # manufacturer: No Name 29 | # slug: other 30 | # custom_field_data: 31 | # text_field: Description 32 | # interfaces: 33 | # - name: eth0 34 | # type: 1000base-t 35 | # mgmt_only: True 36 | # - name: eth1 37 | # type: 1000base-t 38 | # console_server_ports: 39 | # - name_template: ttyS[1-48] 40 | # type: rj-45 41 | # power_ports: 42 | # - name_template: psu[0-1] 43 | # type: iec-60320-c14 44 | # maximum_draw: 35 45 | # allocated_draw: 35 46 | # front_ports: 47 | # - name_template: front[1-2] 48 | # type: 8p8c 49 | # rear_port_template: rear[0-1] 50 | # rear_port_position_template: "[1-2]" 51 | # rear_ports: 52 | # - name_template: rear[0-1] 53 | # type: 8p8c 54 | # positions_template: "[2-3]" 55 | # device_bays: 56 | # - name: bay0 # both non-template and template field specified; non-template field takes precedence 57 | # name_template: bay[0-9] 58 | # label: test0 59 | # label_template: test[0-5,9,6-8] 60 | # description: Test description 61 | # power_outlets: 62 | # - name_template: outlet[0-1] 63 | # type: iec-60320-c5 64 | # power_port: psu0 65 | # feed_leg: B 66 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/prefixes.py: -------------------------------------------------------------------------------- 1 | from dcim.constants import LOCATION_SCOPE_TYPES 2 | from ipam.models import VLAN, VRF, Prefix, Role 3 | from netaddr import IPNetwork 4 | from tenancy.models import Tenant, TenantGroup 5 | 6 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 7 | from netbox_initializers.initializers.utils import get_scope_details 8 | 9 | MATCH_PARAMS = ["prefix", "scope", "vrf", "vlan"] 10 | OPTIONAL_ASSOCS = { 11 | "tenant": (Tenant, "name"), 12 | "tenant_group": (TenantGroup, "name"), 13 | "vlan": (VLAN, "name"), 14 | "role": (Role, "name"), 15 | "vrf": (VRF, "name"), 16 | } 17 | 18 | 19 | class PrefixInitializer(BaseInitializer): 20 | data_file_name = "prefixes.yml" 21 | 22 | def load_data(self): 23 | prefixes = self.load_yaml() 24 | if prefixes is None: 25 | return 26 | for params in prefixes: 27 | custom_field_data = self.pop_custom_fields(params) 28 | tags = params.pop("tags", None) 29 | 30 | params["prefix"] = IPNetwork(params["prefix"]) 31 | 32 | if scope := params.pop("scope"): 33 | params["scope_type"], params["scope_id"] = get_scope_details(scope, LOCATION_SCOPE_TYPES) 34 | 35 | for assoc, details in OPTIONAL_ASSOCS.items(): 36 | if assoc in params: 37 | model, field = details 38 | query = {field: params.pop(assoc)} 39 | params[assoc] = model.objects.get(**query) 40 | 41 | matching_params, defaults = self.split_params(params, MATCH_PARAMS) 42 | prefix, created = Prefix.objects.get_or_create(**matching_params, defaults=defaults) 43 | 44 | if created: 45 | print("📌 Created Prefix", prefix.prefix) 46 | 47 | self.set_custom_fields_values(prefix, custom_field_data) 48 | self.set_tags(prefix, tags) 49 | 50 | 51 | register_initializer("prefixes", PrefixInitializer) 52 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/virtualization_interfaces.py: -------------------------------------------------------------------------------- 1 | from dcim.models import MACAddress 2 | from virtualization.models import VirtualMachine, VMInterface 3 | 4 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 5 | 6 | MATCH_PARAMS = ["name", "virtual_machine"] 7 | REQUIRED_ASSOCS = {"virtual_machine": (VirtualMachine, "name")} 8 | OPTIONAL_ASSOCS = {"primary_mac_address": (MACAddress, "mac_address")} 9 | 10 | 11 | class VMInterfaceInitializer(BaseInitializer): 12 | data_file_name = "virtualization_interfaces.yml" 13 | 14 | def load_data(self): 15 | interfaces = self.load_yaml() 16 | if interfaces is None: 17 | return 18 | for params in interfaces: 19 | custom_field_data = self.pop_custom_fields(params) 20 | tags = params.pop("tags", None) 21 | mac_addresses = params.pop("mac_addresses", None) 22 | 23 | for assoc, details in REQUIRED_ASSOCS.items(): 24 | model, field = details 25 | query = {field: params.pop(assoc)} 26 | 27 | params[assoc] = model.objects.get(**query) 28 | 29 | for assoc, details in OPTIONAL_ASSOCS.items(): 30 | if assoc in params: 31 | model, field = details 32 | query = {field: params.pop(assoc)} 33 | 34 | params[assoc] = model.objects.get(**query) 35 | 36 | matching_params, defaults = self.split_params(params, MATCH_PARAMS) 37 | interface, created = VMInterface.objects.get_or_create( 38 | **matching_params, defaults=defaults 39 | ) 40 | 41 | if created: 42 | print("🧷 Created interface", interface.name, interface.virtual_machine.name) 43 | 44 | self.set_custom_fields_values(interface, custom_field_data) 45 | self.set_tags(interface, tags) 46 | self.set_mac_addresses(interface, mac_addresses) 47 | 48 | 49 | register_initializer("virtualization_interfaces", VMInterfaceInitializer) 50 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/virtual_machines.py: -------------------------------------------------------------------------------- 1 | from dcim.models import DeviceRole, Platform, Site 2 | from tenancy.models import Tenant 3 | from virtualization.models import Cluster, VirtualMachine 4 | 5 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 6 | 7 | MATCH_PARAMS = ["cluster", "name"] 8 | REQUIRED_ASSOCS = {"cluster": (Cluster, "name")} 9 | OPTIONAL_ASSOCS = { 10 | "tenant": (Tenant, "name"), 11 | "site": (Site, "name"), 12 | "platform": (Platform, "name"), 13 | "role": (DeviceRole, "name"), 14 | } 15 | 16 | 17 | class VirtualMachineInitializer(BaseInitializer): 18 | data_file_name = "virtual_machines.yml" 19 | 20 | def load_data(self): 21 | virtual_machines = self.load_yaml() 22 | if virtual_machines is None: 23 | return 24 | for params in virtual_machines: 25 | custom_field_data = self.pop_custom_fields(params) 26 | tags = params.pop("tags", None) 27 | 28 | # primary ips are handled later in `270_primary_ips.py` 29 | params.pop("primary_ip4", None) 30 | params.pop("primary_ip6", None) 31 | params.pop("primary_ip4_vrf", None) 32 | params.pop("primary_ip6_vrf", None) 33 | 34 | for assoc, details in REQUIRED_ASSOCS.items(): 35 | model, field = details 36 | query = {field: params.pop(assoc)} 37 | 38 | params[assoc] = model.objects.get(**query) 39 | 40 | for assoc, details in OPTIONAL_ASSOCS.items(): 41 | if assoc in params: 42 | model, field = details 43 | query = {field: params.pop(assoc)} 44 | 45 | params[assoc] = model.objects.get(**query) 46 | 47 | matching_params, defaults = self.split_params(params, MATCH_PARAMS) 48 | virtual_machine, created = VirtualMachine.objects.get_or_create( 49 | **matching_params, defaults=defaults 50 | ) 51 | 52 | if created: 53 | print("🖥️ Created virtual machine", virtual_machine.name) 54 | 55 | self.set_custom_fields_values(virtual_machine, custom_field_data) 56 | self.set_tags(virtual_machine, tags) 57 | 58 | 59 | register_initializer("virtual_machines", VirtualMachineInitializer) 60 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/sites.py: -------------------------------------------------------------------------------- 1 | from dcim.models import Region, Site, SiteGroup 2 | from ipam.models import ASN 3 | from tenancy.models import Tenant 4 | 5 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 6 | 7 | OPTIONAL_ASSOCS = { 8 | "region": (Region, "name"), 9 | "group": (SiteGroup, "name"), 10 | "tenant": (Tenant, "name"), 11 | } 12 | 13 | 14 | class SiteInitializer(BaseInitializer): 15 | data_file_name = "sites.yml" 16 | 17 | def load_data(self): 18 | sites = self.load_yaml() 19 | if sites is None: 20 | return 21 | for params in sites: 22 | custom_field_data = self.pop_custom_fields(params) 23 | tags = params.pop("tags", None) 24 | 25 | for assoc, details in OPTIONAL_ASSOCS.items(): 26 | if assoc in params: 27 | model, field = details 28 | query = {field: params.pop(assoc)} 29 | 30 | params[assoc] = model.objects.get(**query) 31 | 32 | matching_params, defaults = self.split_params(params) 33 | 34 | asnFounds = [] 35 | if defaults.get("asns", 0): 36 | for asn in defaults["asns"]: 37 | found = ASN.objects.filter(asn=asn).first() 38 | if found: 39 | asnFounds += [found] 40 | 41 | if len(defaults["asns"]) != len(asnFounds): 42 | print( 43 | "⚠️ Unable to create Site '{0}': all ASNs could not be found".format( 44 | params.get("name") 45 | ) 46 | ) 47 | 48 | # asns will be assosciated below 49 | del defaults["asns"] 50 | 51 | site, created = Site.objects.get_or_create(**matching_params, defaults=defaults) 52 | 53 | if created: 54 | print("📍 Created site", site.name) 55 | 56 | self.set_custom_fields_values(site, custom_field_data) 57 | self.set_tags(site, tags) 58 | 59 | for asn in asnFounds: 60 | site.asns.add(asn) 61 | print(" 👤 Assigned asn %s to site %s" % (asn, site.name)) 62 | 63 | site.save() 64 | 65 | 66 | register_initializer("sites", SiteInitializer) 67 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/vlan_groups.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.models import ContentType 2 | from ipam.models import VLANGroup 3 | 4 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 5 | 6 | OPTIONAL_ASSOCS = {"scope": (None, "name")} 7 | 8 | 9 | class VLANGroupInitializer(BaseInitializer): 10 | data_file_name = "vlan_groups.yml" 11 | 12 | def load_data(self): 13 | vlan_groups = self.load_yaml() 14 | if vlan_groups is None: 15 | return 16 | for params in vlan_groups: 17 | custom_field_data = self.pop_custom_fields(params) 18 | tags = params.pop("tags", None) 19 | 20 | for assoc, details in OPTIONAL_ASSOCS.items(): 21 | if assoc in params: 22 | model, field = details 23 | query = {field: params.pop(assoc)} 24 | # Get model from Contenttype 25 | scope_type = params.pop("scope_type", None) 26 | if not scope_type: 27 | print( 28 | f"VLAN Group '{params['name']}': scope_type is missing from VLAN Group" 29 | ) 30 | continue 31 | app_label, model = str(scope_type).split(".") 32 | ct = ContentType.objects.filter(app_label=app_label, model=model).first() 33 | if not ct: 34 | print( 35 | f"VLAN Group '{params['name']}': ContentType for " 36 | + f"app_label = '{app_label}' and model = '{model}' not found" 37 | ) 38 | continue 39 | params["scope_id"] = ct.model_class().objects.get(**query).id 40 | params["scope_type"] = ct 41 | 42 | matching_params, defaults = self.split_params(params) 43 | vlan_group, created = VLANGroup.objects.get_or_create( 44 | **matching_params, defaults=defaults 45 | ) 46 | 47 | if created: 48 | print("🏘️ Created VLAN Group", vlan_group.name) 49 | 50 | self.set_custom_fields_values(vlan_group, custom_field_data) 51 | self.set_tags(vlan_group, tags) 52 | 53 | 54 | register_initializer("vlan_groups", VLANGroupInitializer) 55 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/config_contexts.py: -------------------------------------------------------------------------------- 1 | from dcim import models as dcim 2 | from extras.models import ConfigContext 3 | from tenancy import models as tenancy 4 | from virtualization import models as virtualization 5 | 6 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 7 | 8 | MATCH_PARAMS = ["name"] 9 | OPTIONAL_MANY_ASSOCS = { 10 | "regions": (dcim.Region, "name"), 11 | "site_groups": (dcim.SiteGroup, "name"), 12 | "sites": (dcim.Site, "name"), 13 | "locations": (dcim.Location, "name"), 14 | "device_types": (dcim.DeviceType, "model"), 15 | "roles": (dcim.DeviceRole, "name"), 16 | "platforms": (dcim.Platform, "name"), 17 | "cluster_types": (virtualization.ClusterType, "name"), 18 | "cluster_groups": (virtualization.ClusterGroup, "name"), 19 | "clusters": (virtualization.Cluster, "name"), 20 | "tenant_groups": (tenancy.TenantGroup, "name"), 21 | "tenants": (tenancy.Tenant, "name"), 22 | } 23 | 24 | 25 | class ConfigContextInitializer(BaseInitializer): 26 | data_file_name = "config_contexts.yml" 27 | 28 | def load_data(self): 29 | contexts = self.load_yaml() 30 | if contexts is None: 31 | return 32 | for params in contexts: 33 | tags = params.pop("tags", None) 34 | 35 | # siphon off params that represent many to many relationships 36 | many_assocs = {} 37 | for many_assoc in OPTIONAL_MANY_ASSOCS.keys(): 38 | if many_assoc in params: 39 | many_assocs[many_assoc] = params.pop(many_assoc) 40 | 41 | matching_params, defaults = self.split_params(params, MATCH_PARAMS) 42 | context, created = ConfigContext.objects.get_or_create( 43 | **matching_params, defaults=defaults 44 | ) 45 | 46 | # process the many to many relationships 47 | for assoc_field, assocs in many_assocs.items(): 48 | model, field = OPTIONAL_MANY_ASSOCS[assoc_field] 49 | for assoc in assocs: 50 | query = {field: assoc} 51 | getattr(context, assoc_field).add(model.objects.get(**query)) 52 | 53 | if created: 54 | print("🖥️ Created config context", context.name) 55 | 56 | self.set_tags(context, tags) 57 | 58 | 59 | register_initializer("config_contexts", ConfigContextInitializer) 60 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/devices.py: -------------------------------------------------------------------------------- 1 | from dcim.models import Device, DeviceRole, DeviceType, Location, Platform, Rack, Site 2 | from extras.models import ConfigTemplate 3 | from tenancy.models import Tenant 4 | from virtualization.models import Cluster 5 | 6 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 7 | 8 | MATCH_PARAMS = ["device_type", "name", "site"] 9 | REQUIRED_ASSOCS = { 10 | "role": (DeviceRole, "name"), 11 | "device_type": (DeviceType, "model"), 12 | "site": (Site, "name"), 13 | } 14 | OPTIONAL_ASSOCS = { 15 | "cluster": (Cluster, "name"), 16 | "config_template": (ConfigTemplate, "name"), 17 | "location": (Location, "name"), 18 | "platform": (Platform, "name"), 19 | "rack": (Rack, "name"), 20 | "tenant": (Tenant, "name"), 21 | } 22 | 23 | 24 | class DeviceInitializer(BaseInitializer): 25 | data_file_name = "devices.yml" 26 | 27 | def load_data(self): 28 | devices = self.load_yaml() 29 | if devices is None: 30 | return 31 | for params in devices: 32 | custom_field_data = self.pop_custom_fields(params) 33 | tags = params.pop("tags", None) 34 | 35 | # primary ips are handled later in `380_primary_ips.py` 36 | params.pop("primary_ip4", None) 37 | params.pop("primary_ip6", None) 38 | params.pop("primary_ip4_vrf", None) 39 | params.pop("primary_ip6_vrf", None) 40 | 41 | for assoc, details in REQUIRED_ASSOCS.items(): 42 | model, field = details 43 | query = {field: params.pop(assoc)} 44 | 45 | params[assoc] = model.objects.get(**query) 46 | 47 | for assoc, details in OPTIONAL_ASSOCS.items(): 48 | if assoc in params: 49 | model, field = details 50 | query = {field: params.pop(assoc)} 51 | 52 | params[assoc] = model.objects.get(**query) 53 | 54 | matching_params, defaults = self.split_params(params, MATCH_PARAMS) 55 | device, created = Device.objects.get_or_create(**matching_params, defaults=defaults) 56 | 57 | if created: 58 | print("🖥️ Created device", device.name) 59 | 60 | self.set_custom_fields_values(device, custom_field_data) 61 | self.set_tags(device, tags) 62 | 63 | 64 | register_initializer("devices", DeviceInitializer) 65 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/primary_ips.py: -------------------------------------------------------------------------------- 1 | from dcim.models import Device 2 | from ipam.models import VRF, IPAddress 3 | from virtualization.models import VirtualMachine 4 | 5 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 6 | 7 | OPTIONAL_ASSOCS = { 8 | "primary_ip4": (IPAddress, "address"), 9 | "primary_ip6": (IPAddress, "address"), 10 | } 11 | 12 | 13 | # Used to cache VRF IDs so we don't need have to query NetBox all the time. 14 | vrf_id_cache = {} 15 | 16 | 17 | def get_vrf_id(vrf_name): 18 | if vrf_name not in vrf_id_cache: 19 | if vrf_name is None or vrf_name == "": 20 | return None 21 | vrf = VRF.objects.get(name=vrf_name) 22 | vrf_id_cache[vrf_name] = vrf.id 23 | 24 | return vrf_id_cache[vrf_name] 25 | 26 | 27 | def link_primary_ip(assets, asset_model): 28 | for params in assets: 29 | primary_ip_fields = set(params) & {"primary_ip4", "primary_ip6"} 30 | if not primary_ip_fields: 31 | continue 32 | 33 | for assoc, details in OPTIONAL_ASSOCS.items(): 34 | if assoc in params: 35 | model, field = details 36 | query = {field: params.pop(assoc)} 37 | 38 | if assoc in primary_ip_fields: 39 | query["vrf"] = get_vrf_id(params.get(assoc + "_vrf")) 40 | 41 | try: 42 | params[assoc] = model.objects.get(**query) 43 | except model.DoesNotExist: 44 | primary_ip_fields -= {assoc} 45 | print(f"⚠️ IP Address '{query[field]}' not found") 46 | 47 | asset = asset_model.objects.get(name=params["name"]) 48 | for field in primary_ip_fields: 49 | if getattr(asset, field) != params[field]: 50 | setattr(asset, field, params[field]) 51 | print(f"🔗 Define primary IP '{params[field].address}' on '{asset.name}'") 52 | asset.save() 53 | 54 | 55 | class PrimaryIPInitializer(BaseInitializer): 56 | def load_data(self): 57 | devices = self.load_yaml(data_file_name="devices.yml") 58 | virtual_machines = self.load_yaml(data_file_name="virtual_machines.yml") 59 | 60 | if devices is None and virtual_machines is None: 61 | return 62 | if devices is not None: 63 | link_primary_ip(devices, Device) 64 | if virtual_machines is not None: 65 | link_primary_ip(virtual_machines, VirtualMachine) 66 | 67 | 68 | register_initializer("primary_ips", PrimaryIPInitializer) 69 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/__init__.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: F401 2 | # All initializers must be imported here, to be registered 3 | from .aggregates import AggregateInitializer 4 | from .asns import ASNInitializer 5 | from .cables import CableInitializer 6 | from .circuit_types import CircuitTypeInitializer 7 | from .circuits import CircuitInitializer 8 | from .cluster_groups import ClusterGroupInitializer 9 | from .cluster_types import ClusterTypesInitializer 10 | from .clusters import ClusterInitializer 11 | from .config_contexts import ConfigContextInitializer 12 | from .config_templates import ConfigTemplateInitializer 13 | from .contact_groups import ContactGroupInitializer 14 | from .contact_roles import ContactRoleInitializer 15 | from .contacts import ContactInitializer 16 | from .custom_fields import CustomFieldInitializer 17 | from .custom_links import CustomLinkInitializer 18 | from .device_roles import DeviceRoleInitializer 19 | from .device_types import DeviceTypeInitializer 20 | from .devices import DeviceInitializer 21 | from .groups import GroupInitializer 22 | from .interfaces import InterfaceInitializer 23 | from .ip_addresses import IPAddressInitializer 24 | from .locations import LocationInitializer 25 | from .macs import MACAddressInitializer 26 | from .manufacturers import ManufacturerInitializer 27 | from .object_permissions import ObjectPermissionInitializer 28 | from .platforms import PlatformInitializer 29 | from .power_feeds import PowerFeedInitializer 30 | from .power_panels import PowerPanelInitializer 31 | from .prefix_vlan_roles import RoleInitializer 32 | from .prefixes import PrefixInitializer 33 | from .primary_ips import PrimaryIPInitializer 34 | from .providers import ProviderInitializer 35 | from .rack_roles import RackRoleInitializer 36 | from .rack_types import RackTypeInitializer 37 | from .racks import RackInitializer 38 | from .regions import RegionInitializer 39 | from .rirs import RIRInitializer 40 | from .route_targets import RouteTargetInitializer 41 | from .service_templates import ServiceTemplateInitializer 42 | from .services import ServiceInitializer 43 | from .site_groups import SiteGroupInitializer 44 | from .sites import SiteInitializer 45 | from .tags import TagInitializer 46 | from .tenant_groups import TenantGroupInitializer 47 | from .tenants import TenantInitializer 48 | from .users import UserInitializer 49 | from .virtual_machines import VirtualMachineInitializer 50 | from .virtualization_interfaces import VMInterfaceInitializer 51 | from .vlan_groups import VLANGroupInitializer 52 | from .vlans import VLANInitializer 53 | from .vrfs import VRFInitializer 54 | from .webhooks import WebhookInitializer 55 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/cables.yml: -------------------------------------------------------------------------------- 1 | # # Required parameters for termination X ('a' or 'b'): 2 | # # 3 | # # ``` 4 | # # termination_x_name -> name of interface 5 | # # termination_x_device -> name of the device interface belongs to 6 | # # termination_x_class -> required if different than 'Interface' which is the default 7 | # # ``` 8 | # # 9 | # # Supported termination classes: Interface, ConsolePort, ConsoleServerPort, FrontPort, RearPort, PowerPort, PowerOutlet 10 | # # 11 | # # 12 | # # If a termination is a circuit then the required parameter is termination_x_circuit. 13 | # # Required parameters for a circuit termination: 14 | # # 15 | # # ``` 16 | # # termination_x_circuit: 17 | # # term_side -> termination side of a circuit. Must be A or B 18 | # # cid -> circuit ID value 19 | # # scope: 20 | # # type -> select one of the following: region, site, sitegroup, location 21 | # # name -> name of the object in the respective scope type 22 | # # ``` 23 | # # 24 | # # If a termination is a power feed then the required parameter is termination_x_feed. 25 | # # 26 | # # ``` 27 | # # termination_x_feed: 28 | # # name -> name of the PowerFeed object 29 | # # power_panel: 30 | # # name -> name of the PowerPanel the PowerFeed is attached to 31 | # # site -> name of the Site in which the PowerPanel is present 32 | # # ``` 33 | # # 34 | # # Any other Cable parameters supported by Netbox are supported as the top level keys, e.g. 'type', 'status', etc. 35 | # # 36 | # # - termination_a_name: console 37 | # # termination_a_device: spine 38 | # # termination_a_class: ConsolePort 39 | # # termination_b_name: tty9 40 | # # termination_b_device: console-server 41 | # # termination_b_class: ConsoleServerPort 42 | # # type: cat6 43 | # # 44 | # - termination_a_name: to-server02 45 | # termination_a_device: server01 46 | # termination_b_name: to-server01 47 | # termination_b_device: server02 48 | # status: planned 49 | # type: mmf 50 | 51 | # - termination_a_name: eth0 52 | # termination_a_device: server02 53 | # termination_b_circuit: 54 | # term_side: A 55 | # cid: Circuit_ID-1 56 | # scope: 57 | # type: site 58 | # name: AMS 1 59 | # type: cat6 60 | 61 | # - termination_a_name: psu0 62 | # termination_a_device: server04 63 | # termination_a_class: PowerPort 64 | # termination_b_feed: 65 | # name: power feed 1 66 | # power_panel: 67 | # name: power panel AMS 1 68 | # site: AMS 1 69 | 70 | # - termination_a_name: outlet1 71 | # termination_a_device: server04 72 | # termination_a_class: PowerOutlet 73 | # termination_b_name: psu1 74 | # termination_b_device: server04 75 | # termination_b_class: PowerPort 76 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/yaml/custom_fields.yml: -------------------------------------------------------------------------------- 1 | ## Possible Choices: 2 | ## type: 3 | ## - text 4 | ## - integer 5 | ## - boolean 6 | ## - date 7 | ## - url 8 | ## - select 9 | ## - multiselect 10 | ## - object 11 | ## - multiobject 12 | ## filter_logic: 13 | ## - disabled 14 | ## - loose 15 | ## - exact 16 | ## ui_visibility: 17 | ## - read-write 18 | ## - read-only 19 | ## - hidden 20 | ## 21 | ## Examples: 22 | 23 | # text_field: 24 | # type: text 25 | # label: Custom Text 26 | # description: Enter text in a text field. 27 | # required: false 28 | # weight: 0 29 | # group_name: group1 30 | # ui_visibility: read-only 31 | # search_weight: 100 32 | # on_objects: 33 | # - dcim.models.Device 34 | # - dcim.models.Rack 35 | # - dcim.models.RackType 36 | # - dcim.models.Site 37 | # - dcim.models.DeviceType 38 | # - ipam.models.IPAddress 39 | # - ipam.models.Prefix 40 | # - tenancy.models.Tenant 41 | # - virtualization.models.VirtualMachine 42 | # integer_field: 43 | # type: integer 44 | # label: Custom Number 45 | # description: Enter numbers into an integer field. 46 | # required: true 47 | # filter_logic: loose 48 | # validation_minimum: 0 49 | # validation_maximum: 255 50 | # weight: 10 51 | # group_name: group1 52 | # is_cloneable: false 53 | # on_objects: 54 | # - tenancy.models.Tenant 55 | # select_field: 56 | # type: select 57 | # label: Choose between items 58 | # required: false 59 | # filter_logic: exact 60 | # weight: 30 61 | # default: First Item 62 | # is_cloneable: true 63 | # on_objects: 64 | # - dcim.models.Device 65 | # choices: 66 | # - First Item 67 | # - Second Item 68 | # - Third Item 69 | # - Fifth Item 70 | # - Fourth Item 71 | # boolean_field: 72 | # type: boolean 73 | # label: Yes Or No? 74 | # required: true 75 | # filter_logic: loose 76 | # default: "false" # important: put "false" in quotes! 77 | # weight: 90 78 | # on_objects: 79 | # - dcim.models.Device 80 | # url_field: 81 | # type: url 82 | # label: Hyperlink 83 | # description: Link to something nice. 84 | # required: true 85 | # filter_logic: disabled 86 | # validation_regex: ^https:// 87 | # on_objects: 88 | # - tenancy.models.Tenant 89 | # date_field: 90 | # type: date 91 | # label: Important Date 92 | # required: false 93 | # filter_logic: disabled 94 | # on_objects: 95 | # - dcim.models.Device 96 | # multiobject_field: 97 | # type: multiobject 98 | # label: Related Objects 99 | # description: IP addresses that belong to this location 100 | # required: true 101 | # filter_logic: loose 102 | # on_objects: 103 | # - dcim.models.Location 104 | # related_object_type: ipam.models.IPAddress 105 | # object_field: 106 | # type: object 107 | # label: ASN 108 | # description: This device has an ASN now 109 | # required: false 110 | # filter_logic: loose 111 | # on_objects: 112 | # - dcim.models.Device 113 | # related_object_type: ipam.models.ASN 114 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/object_permissions.py: -------------------------------------------------------------------------------- 1 | from core.models import ObjectType 2 | from users.models import Group, ObjectPermission, User 3 | 4 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 5 | 6 | 7 | class ObjectPermissionInitializer(BaseInitializer): 8 | data_file_name = "object_permissions.yml" 9 | 10 | def load_data(self): 11 | object_permissions = self.load_yaml() 12 | if object_permissions is None: 13 | return 14 | for permission_name, permission_details in object_permissions.items(): 15 | object_permission, created = ObjectPermission.objects.get_or_create( 16 | name=permission_name, 17 | defaults={ 18 | "description": permission_details["description"], 19 | "enabled": permission_details["enabled"], 20 | "actions": permission_details["actions"], 21 | }, 22 | ) 23 | 24 | if permission_details.get("constraints", 0): 25 | object_permission.constraints = permission_details["constraints"] 26 | 27 | if permission_details.get("object_types", 0): 28 | object_types = permission_details["object_types"] 29 | 30 | if object_types == "all": 31 | object_permission.object_types.set(ObjectType.objects.all()) 32 | 33 | else: 34 | for app_label, models in object_types.items(): 35 | if models == "all": 36 | app_models = ObjectType.objects.filter(app_label=app_label) 37 | 38 | for app_model in app_models: 39 | object_permission.object_types.add(app_model.id) 40 | else: 41 | # There is 42 | for model in models: 43 | object_permission.object_types.add( 44 | ObjectType.objects.get(app_label=app_label, model=model) 45 | ) 46 | if created: 47 | print("🔓 Created object permission", object_permission.name) 48 | 49 | if permission_details.get("groups", 0): 50 | for groupname in permission_details["groups"]: 51 | group = Group.objects.filter(name=groupname).first() 52 | 53 | if group: 54 | object_permission.groups.add(group) 55 | print( 56 | " 👥 Assigned group %s object permission of %s" 57 | % (groupname, object_permission.name) 58 | ) 59 | 60 | if permission_details.get("users", 0): 61 | for username in permission_details["users"]: 62 | user = User.objects.filter(username=username).first() 63 | 64 | if user: 65 | object_permission.users.add(user) 66 | print( 67 | " 👤 Assigned user %s object permission of %s" 68 | % (username, object_permission.name) 69 | ) 70 | 71 | object_permission.save() 72 | 73 | 74 | register_initializer("object_permissions", ObjectPermissionInitializer) 75 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/ip_addresses.py: -------------------------------------------------------------------------------- 1 | from dcim.models import Device, Interface 2 | from django.contrib.contenttypes.models import ContentType 3 | from django.db.models import Q 4 | from ipam.models import VRF, IPAddress 5 | from netaddr import IPNetwork 6 | from tenancy.models import Tenant 7 | from virtualization.models import VirtualMachine, VMInterface 8 | 9 | from netbox_initializers.initializers.base import ( 10 | BaseInitializer, 11 | InitializationError, 12 | register_initializer, 13 | ) 14 | 15 | MATCH_PARAMS = ["address", "vrf", "vrf_id", "assigned_object_id", "assigned_object_type"] 16 | OPTIONAL_ASSOCS = { 17 | "tenant": (Tenant, "name"), 18 | "vrf": (VRF, "name"), 19 | "interface": (Interface, "name"), 20 | } 21 | 22 | VM_INTERFACE_CT = ContentType.objects.filter( 23 | Q(app_label="virtualization", model="vminterface") 24 | ).first() 25 | INTERFACE_CT = ContentType.objects.filter(Q(app_label="dcim", model="interface")).first() 26 | 27 | 28 | class IPAddressInitializer(BaseInitializer): 29 | data_file_name = "ip_addresses.yml" 30 | 31 | def load_data(self): 32 | ip_addresses = self.load_yaml() 33 | if ip_addresses is None: 34 | return 35 | for params in ip_addresses: 36 | custom_field_data = self.pop_custom_fields(params) 37 | tags = params.pop("tags", None) 38 | 39 | vm = params.pop("virtual_machine", None) 40 | device = params.pop("device", None) 41 | params["address"] = IPNetwork(params["address"]) 42 | 43 | if vm and device: 44 | raise InitializationError( 45 | "IP Address can only specify one of the following: virtual_machine or device." 46 | ) 47 | 48 | for assoc, details in OPTIONAL_ASSOCS.items(): 49 | if assoc in params: 50 | model, field = details 51 | if assoc == "interface": 52 | if vm: 53 | vm_id = VirtualMachine.objects.get(name=vm).id 54 | query = {"name": params.pop(assoc), "virtual_machine_id": vm_id} 55 | params["assigned_object_type"] = VM_INTERFACE_CT 56 | params["assigned_object_id"] = VMInterface.objects.get(**query).id 57 | elif device: 58 | dev_id = Device.objects.get(name=device).id 59 | query = {"name": params.pop(assoc), "device_id": dev_id} 60 | params["assigned_object_type"] = INTERFACE_CT 61 | params["assigned_object_id"] = Interface.objects.get(**query).id 62 | elif assoc == "vrf" and params[assoc] is None: 63 | params["vrf_id"] = None 64 | else: 65 | query = {field: params.pop(assoc)} 66 | 67 | params[assoc] = model.objects.get(**query) 68 | 69 | matching_params, defaults = self.split_params(params, MATCH_PARAMS) 70 | ip_address, created = IPAddress.objects.get_or_create( 71 | **matching_params, defaults=defaults 72 | ) 73 | 74 | if created: 75 | print("🧬 Created IP Address", ip_address.address) 76 | 77 | self.set_custom_fields_values(ip_address, custom_field_data) 78 | self.set_tags(ip_address, tags) 79 | 80 | 81 | register_initializer("ip_addresses", IPAddressInitializer) 82 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/interfaces.py: -------------------------------------------------------------------------------- 1 | from dcim.models import Device, Interface 2 | from ipam.models import VLAN 3 | 4 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 5 | 6 | MATCH_PARAMS = ["device", "name"] 7 | REQUIRED_ASSOCS = {"device": (Device, "name")} 8 | OPTIONAL_ASSOCS = { 9 | "untagged_vlan": (VLAN, "name"), 10 | } 11 | RELATED_ASSOCS = { 12 | "bridge": (Interface, "name"), 13 | "lag": (Interface, "name"), 14 | "parent": (Interface, "name"), 15 | } 16 | 17 | 18 | class InterfaceInitializer(BaseInitializer): 19 | data_file_name = "interfaces.yml" 20 | 21 | def load_data(self): 22 | interfaces = self.load_yaml() 23 | if interfaces is None: 24 | return 25 | for params in interfaces: 26 | custom_field_data = self.pop_custom_fields(params) 27 | tags = params.pop("tags", None) 28 | 29 | related_interfaces = {k: params.pop(k, None) for k in RELATED_ASSOCS} 30 | 31 | for assoc, details in REQUIRED_ASSOCS.items(): 32 | model, field = details 33 | query = {field: params.pop(assoc)} 34 | 35 | params[assoc] = model.objects.get(**query) 36 | 37 | for assoc, details in OPTIONAL_ASSOCS.items(): 38 | if assoc in params: 39 | model, field = details 40 | query = {field: params.pop(assoc)} 41 | 42 | params[assoc] = model.objects.get(**query) 43 | 44 | matching_params, defaults = self.split_params(params, MATCH_PARAMS) 45 | interface, created = Interface.objects.get_or_create( 46 | **matching_params, defaults=defaults 47 | ) 48 | 49 | if created: 50 | print(f"🧷 Created interface {interface} on {interface.device}") 51 | else: 52 | for name in defaults: 53 | setattr(interface, name, defaults[name]) 54 | interface.save() 55 | 56 | self.set_custom_fields_values(interface, custom_field_data) 57 | self.set_tags(interface, tags) 58 | 59 | for related_field, related_value in related_interfaces.items(): 60 | if not related_value: 61 | continue 62 | 63 | r_model, r_field = RELATED_ASSOCS[related_field] 64 | 65 | if related_field == "parent" and not interface.parent_id: 66 | query = {r_field: related_value, "device": interface.device} 67 | try: 68 | related_obj = r_model.objects.get(**query) 69 | except Interface.DoesNotExist: 70 | print( 71 | f"⚠️ Could not find parent interface with: {query} for interface {interface}" 72 | ) 73 | raise 74 | 75 | interface.parent_id = related_obj.id 76 | interface.save() 77 | print( 78 | f"🧷 Attached interface {interface} on {interface.device} " 79 | f"to parent {related_obj}" 80 | ) 81 | else: 82 | query = { 83 | r_field: related_value, 84 | "device": interface.device, 85 | } 86 | related_obj, rel_obj_created = r_model.objects.get_or_create(**query) 87 | 88 | if rel_obj_created: 89 | print( 90 | f"🧷 Created {related_field} interface {interface} on {interface.device}" 91 | ) 92 | 93 | setattr(interface, f"{related_field}_id", related_obj.id) 94 | interface.save() 95 | 96 | 97 | register_initializer("interfaces", InterfaceInitializer) 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ############################################# 2 | ## https://raw.githubusercontent.com/github/gitignore/main/Python.gitignore 3 | ############################################# 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | cover/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | .pybuilder/ 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | # For a library or package, you might want to ignore these files since the code is 90 | # intended to run in multiple environments; otherwise, check them in: 91 | # .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # poetry 101 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 102 | # This is especially recommended for binary packages to ensure reproducibility, and is more 103 | # commonly ignored for libraries. 104 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 105 | #poetry.lock 106 | 107 | # pdm 108 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 109 | #pdm.lock 110 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 111 | # in version control. 112 | # https://pdm.fming.dev/#use-with-ide 113 | .pdm.toml 114 | 115 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 116 | __pypackages__/ 117 | 118 | # Celery stuff 119 | celerybeat-schedule 120 | celerybeat.pid 121 | 122 | # SageMath parsed files 123 | *.sage.py 124 | 125 | # Environments 126 | .env 127 | .venv 128 | env/ 129 | venv/ 130 | ENV/ 131 | env.bak/ 132 | venv.bak/ 133 | 134 | # Spyder project settings 135 | .spyderproject 136 | .spyproject 137 | 138 | # Rope project settings 139 | .ropeproject 140 | 141 | # mkdocs documentation 142 | /site 143 | 144 | # mypy 145 | .mypy_cache/ 146 | .dmypy.json 147 | dmypy.json 148 | 149 | # Pyre type checker 150 | .pyre/ 151 | 152 | # pytype static type analyzer 153 | .pytype/ 154 | 155 | # Cython debug symbols 156 | cython_debug/ 157 | 158 | # PyCharm 159 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 160 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 161 | # and can be added to the global gitignore or merged into this file. For a more nuclear 162 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 163 | #.idea/ 164 | 165 | 166 | ############################################# 167 | ## https://raw.githubusercontent.com/github/gitignore/main/Global/VisualStudioCode.gitignore 168 | ############################################# 169 | .vscode/* 170 | !.vscode/settings.json 171 | !.vscode/tasks.json 172 | !.vscode/launch.json 173 | !.vscode/extensions.json 174 | !.vscode/*.code-snippets 175 | 176 | # Local History for Visual Studio Code 177 | .history/ 178 | 179 | # Built Visual Studio Code Extensions 180 | *.vsix 181 | 182 | ############################################# 183 | ## netbox-initializer 184 | ############################################# 185 | test/initializer-data 186 | .python-version 187 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/base.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Tuple 3 | 4 | from core.models import ObjectType 5 | from dcim.models import MACAddress 6 | from django.core.exceptions import ObjectDoesNotExist 7 | from extras.models import CustomField, Tag 8 | from ruamel.yaml import YAML 9 | 10 | 11 | class BaseInitializer: 12 | # File name for import; Musst be set in subclass 13 | data_file_name = "" 14 | 15 | def __init__(self, data_file_path: str) -> None: 16 | self.data_file_path = data_file_path 17 | 18 | def load_data(self): 19 | # Must be implemented by specific subclass 20 | pass 21 | 22 | def load_yaml(self, data_file_name=None): 23 | if data_file_name: 24 | yf = Path(f"{self.data_file_path}/{data_file_name}") 25 | else: 26 | yf = Path(f"{self.data_file_path}/{self.data_file_name}") 27 | if not yf.is_file(): 28 | return None 29 | with yf.open("r") as stream: 30 | yaml = YAML(typ="safe") 31 | return yaml.load(stream) 32 | 33 | def pop_custom_fields(self, params): 34 | if "custom_field_data" in params: 35 | return params.pop("custom_field_data") 36 | elif "custom_fields" in params: 37 | print("⚠️ Please rename 'custom_fields' to 'custom_field_data'!") 38 | return params.pop("custom_fields") 39 | 40 | return None 41 | 42 | def set_custom_fields_values(self, entity, custom_field_data): 43 | if not custom_field_data: 44 | return 45 | 46 | missing_cfs = [] 47 | save = False 48 | for key, value in custom_field_data.items(): 49 | try: 50 | cf = CustomField.objects.get(name=key) 51 | except ObjectDoesNotExist: 52 | missing_cfs.append(key) 53 | else: 54 | ct = ObjectType.objects.get_for_model(entity) 55 | if ct not in cf.object_types.all(): 56 | print( 57 | f"⚠️ Custom field {key} is not enabled for {entity}'s model!" 58 | "Please check the 'on_objects' for that custom field in custom_fields.yml" 59 | ) 60 | elif key not in entity.custom_field_data: 61 | entity.custom_field_data[key] = value 62 | save = True 63 | 64 | if missing_cfs: 65 | raise Exception( 66 | f"⚠️ Custom field(s) '{missing_cfs}' requested for {entity} but not found in Netbox!" 67 | "Please chceck the custom_fields.yml" 68 | ) 69 | 70 | if save: 71 | entity.save() 72 | 73 | def set_tags(self, entity, tags): 74 | if not tags: 75 | return 76 | 77 | if not hasattr(entity, "tags"): 78 | raise Exception(f"⚠️ Tags cannot be applied to {entity}'s model") 79 | 80 | ct = ObjectType.objects.get_for_model(entity) 81 | 82 | save = False 83 | for tag in Tag.objects.filter(name__in=tags): 84 | restricted_cts = tag.object_types.all() 85 | if restricted_cts and ct not in restricted_cts: 86 | raise Exception(f"⚠️ Tag {tag} cannot be applied to {entity}'s model") 87 | 88 | entity.tags.add(tag) 89 | save = True 90 | 91 | if save: 92 | entity.save() 93 | 94 | def set_mac_addresses(self, entity, mac_addresses): 95 | if not mac_addresses: 96 | return 97 | 98 | if not hasattr(entity, "mac_addresses"): 99 | raise Exception(f"⚠️ MAC Address cannot be applied to {entity}'s model") 100 | 101 | save = False 102 | for mac_address in MACAddress.objects.filter(mac_address__in=mac_addresses): 103 | 104 | entity.mac_addresses.add(mac_address) 105 | save = True 106 | 107 | if save: 108 | entity.save() 109 | 110 | def split_params(self, params: dict, unique_params: list = None) -> Tuple[dict, dict]: 111 | """Split params dict into dict with matching params and a dict with default values""" 112 | 113 | if unique_params is None: 114 | unique_params = ["name", "slug"] 115 | 116 | matching_params = {} 117 | for unique_param in unique_params: 118 | param = params.pop(unique_param, "__not_set__") 119 | if param != "__not_set__": 120 | matching_params[unique_param] = param 121 | return matching_params, params 122 | 123 | 124 | class InitializationError(Exception): 125 | pass 126 | 127 | 128 | INITIALIZER_ORDER = ( 129 | "users", 130 | "groups", 131 | "object_permissions", 132 | "custom_fields", 133 | "custom_links", 134 | "tags", 135 | "config_templates", 136 | "webhooks", 137 | "tenant_groups", 138 | "tenants", 139 | "site_groups", 140 | "regions", 141 | "rirs", 142 | "asns", 143 | "sites", 144 | "locations", 145 | "manufacturers", 146 | "rack_roles", 147 | "rack_types", 148 | "racks", 149 | "power_panels", 150 | "power_feeds", 151 | "platforms", 152 | "device_roles", 153 | "device_types", 154 | "cluster_types", 155 | "cluster_groups", 156 | "clusters", 157 | "prefix_vlan_roles", 158 | "vlan_groups", 159 | "vlans", 160 | "macs", 161 | "devices", 162 | "interfaces", 163 | "route_targets", 164 | "vrfs", 165 | "aggregates", 166 | "virtual_machines", 167 | "virtualization_interfaces", 168 | "prefixes", 169 | "ip_addresses", 170 | "primary_ips", 171 | "services", 172 | "service_templates", 173 | "providers", 174 | "circuit_types", 175 | "circuits", 176 | "cables", 177 | "config_contexts", 178 | "contact_groups", 179 | "contact_roles", 180 | "contacts", 181 | ) 182 | 183 | 184 | INITIALIZER_REGISTRY = dict() 185 | 186 | 187 | def register_initializer(name: str, initializer): 188 | INITIALIZER_REGISTRY[name] = initializer 189 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/device_types.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from dcim.models import DeviceType, Manufacturer, Region 4 | from dcim.models.device_component_templates import ( 5 | ConsolePortTemplate, 6 | ConsoleServerPortTemplate, 7 | DeviceBayTemplate, 8 | FrontPortTemplate, 9 | InterfaceTemplate, 10 | PowerOutletTemplate, 11 | PowerPortTemplate, 12 | RearPortTemplate, 13 | ) 14 | from tenancy.models import Tenant 15 | from utilities.forms.utils import expand_alphanumeric_pattern 16 | 17 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 18 | 19 | MATCH_PARAMS = ["manufacturer", "model", "slug"] 20 | REQUIRED_ASSOCS = {"manufacturer": (Manufacturer, "name")} 21 | OPTIONAL_ASSOCS = {"region": (Region, "name"), "tenant": (Tenant, "name")} 22 | NESTED_ASSOCS = {"rear_port": (RearPortTemplate, "name"), "power_port": (PowerPortTemplate, "name")} 23 | SUPPORTED_COMPONENTS = { 24 | "interfaces": (InterfaceTemplate, ["name"]), 25 | "console_ports": (ConsolePortTemplate, ["name"]), 26 | "console_server_ports": (ConsoleServerPortTemplate, ["name"]), 27 | "power_ports": (PowerPortTemplate, ["name"]), 28 | "power_outlets": (PowerOutletTemplate, ["name"]), 29 | "rear_ports": (RearPortTemplate, ["name"]), 30 | "front_ports": (FrontPortTemplate, ["name"]), 31 | "device_bays": (DeviceBayTemplate, ["name"]), 32 | } 33 | 34 | 35 | def expand_templates(params: List[dict], device_type: DeviceType) -> List[dict]: 36 | templateable_fields = ["name", "label", "positions", "rear_port", "rear_port_position"] 37 | 38 | expanded = [] 39 | for param in params: 40 | param["device_type"] = device_type 41 | expanded_fields = {} 42 | has_plain_fields = False 43 | 44 | for field in templateable_fields: 45 | template_value = param.pop(f"{field}_template", None) 46 | 47 | if field in param: 48 | has_plain_fields = True 49 | elif template_value: 50 | expanded_fields[field] = list(expand_alphanumeric_pattern(template_value)) 51 | 52 | if expanded_fields and has_plain_fields: 53 | raise ValueError(f"Mix of plain and template keys provided for {templateable_fields}") 54 | elif not expanded_fields: 55 | expanded.append(param) 56 | continue 57 | 58 | elements = list(expanded_fields.values()) 59 | master_len = len(elements[0]) 60 | if not all([len(elem) == master_len for elem in elements]): 61 | raise ValueError( 62 | f"Number of elements in template fields " 63 | f"{list(expanded_fields.keys())} must be equal" 64 | ) 65 | 66 | for idx in range(master_len): 67 | tmp = param.copy() 68 | for field, value in expanded_fields.items(): 69 | if field in NESTED_ASSOCS: 70 | model, match_key = NESTED_ASSOCS[field] 71 | query = {match_key: value[idx], "device_type": device_type} 72 | tmp[field] = model.objects.get(**query) 73 | else: 74 | tmp[field] = value[idx] 75 | expanded.append(tmp) 76 | return expanded 77 | 78 | 79 | class DeviceTypeInitializer(BaseInitializer): 80 | data_file_name = "device_types.yml" 81 | 82 | def load_data(self): 83 | device_types = self.load_yaml() 84 | if device_types is None: 85 | return 86 | for params in device_types: 87 | custom_field_data = self.pop_custom_fields(params) 88 | tags = params.pop("tags", None) 89 | components = [(v[0], v[1], params.pop(k, [])) for k, v in SUPPORTED_COMPONENTS.items()] 90 | 91 | for assoc, details in REQUIRED_ASSOCS.items(): 92 | model, field = details 93 | query = {field: params.pop(assoc)} 94 | 95 | params[assoc] = model.objects.get(**query) 96 | 97 | for assoc, details in OPTIONAL_ASSOCS.items(): 98 | if assoc in params: 99 | model, field = details 100 | query = {field: params.pop(assoc)} 101 | 102 | params[assoc] = model.objects.get(**query) 103 | 104 | matching_params, defaults = self.split_params(params, MATCH_PARAMS) 105 | device_type, created = DeviceType.objects.get_or_create( 106 | **matching_params, defaults=defaults 107 | ) 108 | 109 | if created: 110 | print("🔡 Created device type", device_type.manufacturer, device_type.model) 111 | 112 | self.set_custom_fields_values(device_type, custom_field_data) 113 | self.set_tags(device_type, tags) 114 | 115 | for component in components: 116 | c_model, c_match_params, c_params = component 117 | c_match_params.append("device_type") 118 | 119 | if not c_params: 120 | continue 121 | 122 | expanded_c_params = expand_templates(c_params, device_type) 123 | 124 | for n_assoc, n_details in NESTED_ASSOCS.items(): 125 | n_model, n_field = n_details 126 | for c_param in expanded_c_params: 127 | if n_assoc in c_param: 128 | n_query = {n_field: c_param[n_assoc], "device_type": device_type} 129 | c_param[n_assoc] = n_model.objects.get(**n_query) 130 | 131 | for new_param in expanded_c_params: 132 | new_matching_params, new_defaults = self.split_params(new_param, c_match_params) 133 | new_obj, new_obj_created = c_model.objects.get_or_create( 134 | **new_matching_params, defaults=new_defaults 135 | ) 136 | if new_obj_created: 137 | print( 138 | f"🧷 Created {c_model._meta} {new_obj} component for device type {device_type}" 139 | ) 140 | 141 | 142 | register_initializer("device_types", DeviceTypeInitializer) 143 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/custom_fields.py: -------------------------------------------------------------------------------- 1 | from extras.models import CustomField, CustomFieldChoiceSet 2 | 3 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 4 | 5 | 6 | def get_class_for_class_path(class_path): 7 | import importlib 8 | 9 | from core.models import ObjectType 10 | 11 | module_name, class_name = class_path.rsplit(".", 1) 12 | module = importlib.import_module(module_name) 13 | clazz = getattr(module, class_name) 14 | return ObjectType.objects.get_for_model(clazz) 15 | 16 | 17 | class CustomFieldInitializer(BaseInitializer): 18 | data_file_name = "custom_fields.yml" 19 | 20 | def load_data(self): 21 | customfields = self.load_yaml() 22 | if customfields is None: 23 | return 24 | for cf_name, cf_details in customfields.items(): 25 | custom_field, created = CustomField.objects.get_or_create(name=cf_name) 26 | 27 | if created: 28 | if cf_details.get("default", False): 29 | custom_field.default = cf_details["default"] 30 | 31 | if cf_details.get("description", False): 32 | custom_field.description = cf_details["description"] 33 | 34 | if cf_details.get("label", False): 35 | custom_field.label = cf_details["label"] 36 | 37 | for object_type in cf_details.get("on_objects", []): 38 | custom_field.object_types.add(get_class_for_class_path(object_type)) 39 | 40 | if cf_details.get("required", False): 41 | custom_field.required = cf_details["required"] 42 | 43 | if cf_details.get("type", False): 44 | custom_field.type = cf_details["type"] 45 | 46 | if cf_details.get("filter_logic", False): 47 | custom_field.filter_logic = cf_details["filter_logic"] 48 | 49 | if cf_details.get("weight", -1) >= 0: 50 | custom_field.weight = cf_details["weight"] 51 | 52 | if cf_details.get("group_name", False): 53 | custom_field.group_name = cf_details["group_name"] 54 | 55 | if cf_details.get("ui_visibility", False): 56 | custom_field.ui_visibility = cf_details["ui_visibility"] 57 | 58 | if cf_details.get("search_weight", -1) >= 0: 59 | custom_field.search_weight = cf_details["search_weight"] 60 | 61 | if cf_details.get("is_cloneable", None) is not None: 62 | custom_field.is_cloneable = cf_details["is_cloneable"] 63 | 64 | # object_type was renamed to related_object_type in netbox 4.0 65 | if cf_details.get("object_type"): 66 | print( 67 | f"⚠️ Unable to create Custom Field '{cf_name}': please rename object_type " 68 | + "to related_object_type" 69 | ) 70 | custom_field.delete() 71 | continue 72 | 73 | # related_object_type should only be applied when type is object, multiobject 74 | if cf_details.get("related_object_type"): 75 | if cf_details.get("type") not in ( 76 | "object", 77 | "multiobject", 78 | ): 79 | print( 80 | f"⚠️ Unable to create Custom Field '{cf_name}': related_object_type is " 81 | + "supported only for object and multiobject types" 82 | ) 83 | custom_field.delete() 84 | continue 85 | custom_field.related_object_type = get_class_for_class_path( 86 | cf_details["related_object_type"] 87 | ) 88 | 89 | # validation_regex should only be applied when type is text, longtext, url 90 | if cf_details.get("validation_regex"): 91 | if cf_details.get("type") not in ( 92 | "text", 93 | "longtext", 94 | "url", 95 | ): 96 | print( 97 | f"⚠️ Unable to create Custom Field '{cf_name}': validation_regex is " 98 | + "supported only for text, longtext and, url types" 99 | ) 100 | custom_field.delete() 101 | continue 102 | custom_field.validation_regex = cf_details["validation_regex"] 103 | 104 | # validation_minimum should only be applied when type is integer 105 | if cf_details.get("validation_minimum"): 106 | if cf_details.get("type") not in ("integer",): 107 | print( 108 | f"⚠️ Unable to create Custom Field '{cf_name}': validation_minimum is " 109 | + "supported only for integer type" 110 | ) 111 | custom_field.delete() 112 | continue 113 | custom_field.validation_minimum = cf_details["validation_minimum"] 114 | 115 | # validation_maximum should only be applied when type is integer 116 | if cf_details.get("validation_maximum"): 117 | if cf_details.get("type") not in ("integer",): 118 | print( 119 | f"⚠️ Unable to create Custom Field '{cf_name}': validation_maximum is " 120 | + "supported only for integer type" 121 | ) 122 | custom_field.delete() 123 | continue 124 | custom_field.validation_maximum = cf_details["validation_maximum"] 125 | 126 | # choices should only be applied when type is select, multiselect 127 | if choices := cf_details.get("choices"): 128 | if cf_details.get("type") not in ( 129 | "select", 130 | "multiselect", 131 | ): 132 | print( 133 | f"⚠️ Unable to create Custom Field '{cf_name}': choices is supported only " 134 | + "for select and multiselect types" 135 | ) 136 | custom_field.delete() 137 | continue 138 | choice_set, _ = CustomFieldChoiceSet.objects.get_or_create( 139 | name=f"{cf_name}_choices" 140 | ) 141 | choice_set.extra_choices = choices 142 | choice_set.save() 143 | custom_field.choice_set = choice_set 144 | 145 | custom_field.save() 146 | 147 | print("🔧 Created custom field", cf_name) 148 | 149 | 150 | register_initializer("custom_fields", CustomFieldInitializer) 151 | -------------------------------------------------------------------------------- /src/netbox_initializers/initializers/cables.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | from circuits.constants import CIRCUIT_TERMINATION_TERMINATION_TYPES 3 | from circuits.models import Circuit, CircuitTermination 4 | from dcim.models import ( 5 | Cable, 6 | CableTermination, 7 | ConsolePort, 8 | ConsoleServerPort, 9 | FrontPort, 10 | Interface, 11 | PowerFeed, 12 | PowerOutlet, 13 | PowerPanel, 14 | PowerPort, 15 | RearPort, 16 | ) 17 | from django.contrib.contenttypes.models import ContentType 18 | from django.db.models import Q 19 | 20 | from netbox_initializers.initializers.base import BaseInitializer, register_initializer 21 | from netbox_initializers.initializers.utils import get_scope_details 22 | 23 | CONSOLE_PORT_TERMINATION = ContentType.objects.get_for_model(ConsolePort) 24 | CONSOLE_SERVER_PORT_TERMINATION = ContentType.objects.get_for_model(ConsoleServerPort) 25 | FRONT_PORT_TERMINATION = ContentType.objects.get_for_model(FrontPort) 26 | REAR_PORT_TERMINATION = ContentType.objects.get_for_model(RearPort) 27 | FRONT_AND_REAR = [FRONT_PORT_TERMINATION, REAR_PORT_TERMINATION] 28 | POWER_PORT_TERMINATION = ContentType.objects.get_for_model(PowerPort) 29 | POWER_OUTLET_TERMINATION = ContentType.objects.get_for_model(PowerOutlet) 30 | POWER_FEED_TERMINATION = ContentType.objects.get_for_model(PowerFeed) 31 | POWER_TERMINATIONS = [POWER_PORT_TERMINATION, POWER_OUTLET_TERMINATION, POWER_FEED_TERMINATION] 32 | 33 | VIRTUAL_INTERFACES = ["bridge", "lag", "virtual"] 34 | 35 | 36 | def get_termination_object(params: dict, side: str): 37 | klass = params.pop(f"termination_{side}_class") 38 | name = params.pop(f"termination_{side}_name", None) 39 | device = params.pop(f"termination_{side}_device", None) 40 | feed_params = params.pop(f"termination_{side}_feed", None) 41 | circuit_params = params.pop(f"termination_{side}_circuit", {}) 42 | 43 | if name and device: 44 | termination = klass.objects.get(name=name, device__name=device) 45 | return termination 46 | elif feed_params: 47 | q = { 48 | "name": feed_params["power_panel"]["name"], 49 | "site__name": feed_params["power_panel"]["site"], 50 | } 51 | power_panel = PowerPanel.objects.get(**q) 52 | termination = PowerFeed.objects.get(name=feed_params["name"], power_panel=power_panel) 53 | return termination 54 | elif circuit_params: 55 | circuit = Circuit.objects.get(cid=circuit_params.pop("cid")) 56 | term_side = circuit_params.pop("term_side").upper() 57 | 58 | if scope := circuit_params.pop("scope", None): 59 | scope_type, scope_id = get_scope_details(scope, CIRCUIT_TERMINATION_TERMINATION_TYPES) 60 | circuit_params["termination_type"] = scope_type 61 | circuit_params["termination_id"] = scope_id 62 | else: 63 | raise ValueError( 64 | f"⚠️ Missing required parameter: 'scope'" 65 | f"for side {term_side} of circuit {circuit}" 66 | ) 67 | 68 | termination, created = CircuitTermination.objects.get_or_create( 69 | circuit=circuit, term_side=term_side, defaults=circuit_params 70 | ) 71 | if created: 72 | print(f"⚡ Created new CircuitTermination {termination}") 73 | 74 | return termination 75 | 76 | raise ValueError( 77 | f"⚠️ Missing parameters for termination_{side}. " 78 | "Need termination_{side}_name AND termination_{side}_device OR termination_{side}_circuit" 79 | ) 80 | 81 | 82 | def get_termination_class_by_name(port_class: str): 83 | if not port_class: 84 | return Interface 85 | 86 | return globals()[port_class] 87 | 88 | 89 | def cable_in_cables(term_a: tuple, term_b: tuple) -> bool: 90 | """Check if cable exist for given terminations. 91 | Each tuple should consist termination object and termination type 92 | """ 93 | 94 | try: 95 | cable_term_a = CableTermination.objects.get( 96 | Q( 97 | termination_id=term_a[0].id, 98 | termination_type=term_a[1], 99 | ) 100 | ) 101 | cable_term_b = CableTermination.objects.get( 102 | Q( 103 | termination_id=term_b[0].id, 104 | termination_type=term_b[1], 105 | ) 106 | ) 107 | except CableTermination.DoesNotExist: 108 | return False 109 | 110 | cable_a = Cable.objects.get(Q(terminations=cable_term_a)) 111 | cable_b = Cable.objects.get(Q(terminations=cable_term_b)) 112 | 113 | return cable_a.id == cable_b.id 114 | 115 | 116 | def check_termination_types(type_a, type_b) -> Tuple[bool, str]: 117 | if type_a in POWER_TERMINATIONS and type_b in POWER_TERMINATIONS: 118 | if type_a == type_b: 119 | return False, "Can't connect the same power terminations together" 120 | elif ( 121 | type_a == POWER_OUTLET_TERMINATION 122 | and type_b == POWER_FEED_TERMINATION 123 | or type_a == POWER_FEED_TERMINATION 124 | and type_b == POWER_OUTLET_TERMINATION 125 | ): 126 | return False, "PowerOutlet can't be connected with PowerFeed" 127 | elif type_a in POWER_TERMINATIONS or type_b in POWER_TERMINATIONS: 128 | return False, "Can't mix power terminations with port terminations" 129 | elif type_a in FRONT_AND_REAR or type_b in FRONT_AND_REAR: 130 | return True, "" 131 | elif ( 132 | type_a == CONSOLE_PORT_TERMINATION 133 | and type_b != CONSOLE_SERVER_PORT_TERMINATION 134 | or type_b == CONSOLE_PORT_TERMINATION 135 | and type_a != CONSOLE_SERVER_PORT_TERMINATION 136 | ): 137 | return False, "ConsolePorts can only be connected to ConsoleServerPorts or Front/Rear ports" 138 | return True, "" 139 | 140 | 141 | def get_cable_name(termination_a: tuple, termination_b: tuple) -> str: 142 | """Returns name of a cable in format: 143 | device_a interface_a <---> interface_b device_b 144 | or for circuits: 145 | circuit_a termination_a <---> termination_b circuit_b 146 | """ 147 | cable_name = [] 148 | 149 | for is_side_b, termination in enumerate([termination_a, termination_b]): 150 | try: 151 | power_panel_id = getattr(termination[0], "power_panel_id", None) 152 | if power_panel_id: 153 | power_feed = PowerPanel.objects.get(id=power_panel_id) 154 | segment = [f"{power_feed}", f"{termination[0]}"] 155 | else: 156 | segment = [f"{termination[0].device}", f"{termination[0]}"] 157 | except AttributeError: 158 | segment = [f"{termination[0].circuit.cid}", f"{termination[0]}"] 159 | 160 | if is_side_b: 161 | segment.reverse() 162 | 163 | cable_name.append(" ".join(segment)) 164 | 165 | return " <---> ".join(cable_name) 166 | 167 | 168 | def check_interface_types(*args): 169 | for termination in args: 170 | try: 171 | if termination.type in VIRTUAL_INTERFACES: 172 | raise Exception( 173 | f"⚠️ Virtual interfaces are not supported for cabling. " 174 | f"Termination {termination.device} {termination} {termination.type}" 175 | ) 176 | except AttributeError: 177 | # CircuitTermination doesn't have a type field 178 | pass 179 | 180 | 181 | def check_terminations_are_free(*args): 182 | any_failed = False 183 | for termination in args: 184 | if termination.cable_id: 185 | any_failed = True 186 | print( 187 | f"⚠️ Termination {termination} is already occupied " 188 | f"with cable #{termination.cable_id}" 189 | ) 190 | if any_failed: 191 | raise Exception("⚠️ At least one end of the cable is already occupied.") 192 | 193 | 194 | class CableInitializer(BaseInitializer): 195 | data_file_name = "cables.yml" 196 | 197 | def load_data(self): 198 | cables = self.load_yaml() 199 | if cables is None: 200 | return 201 | for params in cables: 202 | tags = params.pop("tags", None) 203 | 204 | params["termination_a_class"] = get_termination_class_by_name( 205 | params.get("termination_a_class") 206 | ) 207 | params["termination_b_class"] = get_termination_class_by_name( 208 | params.get("termination_b_class") 209 | ) 210 | 211 | term_a = get_termination_object(params, side="a") 212 | term_b = get_termination_object(params, side="b") 213 | 214 | check_interface_types(term_a, term_b) 215 | 216 | term_a_ct = ContentType.objects.get_for_model(term_a) 217 | term_b_ct = ContentType.objects.get_for_model(term_b) 218 | 219 | types_ok, msg = check_termination_types(term_a_ct, term_b_ct) 220 | cable_name = get_cable_name((term_a, term_a_ct), (term_b, term_b_ct)) 221 | 222 | if not types_ok: 223 | print(f"⚠️ Invalid termination types for {cable_name}. {msg}") 224 | continue 225 | 226 | if cable_in_cables((term_a, term_a_ct), (term_b, term_b_ct)): 227 | continue 228 | 229 | check_terminations_are_free(term_a, term_b) 230 | 231 | cable = Cable.objects.create(**params) 232 | 233 | params_a_term = { 234 | "termination_id": term_a.id, 235 | "termination_type": term_a_ct, 236 | "cable": cable, 237 | "cable_end": "A", 238 | } 239 | CableTermination.objects.create(**params_a_term) 240 | 241 | params_b_term = { 242 | "termination_id": term_b.id, 243 | "termination_type": term_b_ct, 244 | "cable": cable, 245 | "cable_end": "B", 246 | } 247 | CableTermination.objects.create(**params_b_term) 248 | 249 | print(f"🧷 Created cable {cable} {cable_name}") 250 | self.set_tags(cable, tags) 251 | 252 | 253 | register_initializer("cables", CableInitializer) 254 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 3 3 | requires-python = ">=3.10" 4 | 5 | [[package]] 6 | name = "netbox-initializers" 7 | source = { editable = "." } 8 | dependencies = [ 9 | { name = "ruamel-yaml" }, 10 | ] 11 | 12 | [package.dev-dependencies] 13 | dev = [ 14 | { name = "ruff" }, 15 | ] 16 | 17 | [package.metadata] 18 | requires-dist = [{ name = "ruamel-yaml", specifier = ">=0.18.10" }] 19 | 20 | [package.metadata.requires-dev] 21 | dev = [{ name = "ruff", specifier = "==0.12" }] 22 | 23 | [[package]] 24 | name = "ruamel-yaml" 25 | version = "0.18.15" 26 | source = { registry = "https://pypi.org/simple" } 27 | dependencies = [ 28 | { name = "ruamel-yaml-clib", marker = "python_full_version < '3.14' and platform_python_implementation == 'CPython'" }, 29 | ] 30 | sdist = { url = "https://files.pythonhosted.org/packages/3e/db/f3950f5e5031b618aae9f423a39bf81a55c148aecd15a34527898e752cf4/ruamel.yaml-0.18.15.tar.gz", hash = "sha256:dbfca74b018c4c3fba0b9cc9ee33e53c371194a9000e694995e620490fd40700", size = 146865, upload-time = "2025-08-19T11:15:10.694Z" } 31 | wheels = [ 32 | { url = "https://files.pythonhosted.org/packages/d1/e5/f2a0621f1781b76a38194acae72f01e37b1941470407345b6e8653ad7640/ruamel.yaml-0.18.15-py3-none-any.whl", hash = "sha256:148f6488d698b7a5eded5ea793a025308b25eca97208181b6a026037f391f701", size = 119702, upload-time = "2025-08-19T11:15:07.696Z" }, 33 | ] 34 | 35 | [[package]] 36 | name = "ruamel-yaml-clib" 37 | version = "0.2.12" 38 | source = { registry = "https://pypi.org/simple" } 39 | sdist = { url = "https://files.pythonhosted.org/packages/20/84/80203abff8ea4993a87d823a5f632e4d92831ef75d404c9fc78d0176d2b5/ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f", size = 225315, upload-time = "2024-10-20T10:10:56.22Z" } 40 | wheels = [ 41 | { url = "https://files.pythonhosted.org/packages/70/57/40a958e863e299f0c74ef32a3bde9f2d1ea8d69669368c0c502a0997f57f/ruamel.yaml.clib-0.2.12-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:11f891336688faf5156a36293a9c362bdc7c88f03a8a027c2c1d8e0bcde998e5", size = 131301, upload-time = "2024-10-20T10:12:35.876Z" }, 42 | { url = "https://files.pythonhosted.org/packages/98/a8/29a3eb437b12b95f50a6bcc3d7d7214301c6c529d8fdc227247fa84162b5/ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:a606ef75a60ecf3d924613892cc603b154178ee25abb3055db5062da811fd969", size = 633728, upload-time = "2024-10-20T10:12:37.858Z" }, 43 | { url = "https://files.pythonhosted.org/packages/35/6d/ae05a87a3ad540259c3ad88d71275cbd1c0f2d30ae04c65dcbfb6dcd4b9f/ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd5415dded15c3822597455bc02bcd66e81ef8b7a48cb71a33628fc9fdde39df", size = 722230, upload-time = "2024-10-20T10:12:39.457Z" }, 44 | { url = "https://files.pythonhosted.org/packages/7f/b7/20c6f3c0b656fe609675d69bc135c03aac9e3865912444be6339207b6648/ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76", size = 686712, upload-time = "2024-10-20T10:12:41.119Z" }, 45 | { url = "https://files.pythonhosted.org/packages/cd/11/d12dbf683471f888d354dac59593873c2b45feb193c5e3e0f2ebf85e68b9/ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6", size = 663936, upload-time = "2024-10-21T11:26:37.419Z" }, 46 | { url = "https://files.pythonhosted.org/packages/72/14/4c268f5077db5c83f743ee1daeb236269fa8577133a5cfa49f8b382baf13/ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd", size = 696580, upload-time = "2024-10-21T11:26:39.503Z" }, 47 | { url = "https://files.pythonhosted.org/packages/30/fc/8cd12f189c6405a4c1cf37bd633aa740a9538c8e40497c231072d0fef5cf/ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a", size = 663393, upload-time = "2024-12-11T19:58:13.873Z" }, 48 | { url = "https://files.pythonhosted.org/packages/80/29/c0a017b704aaf3cbf704989785cd9c5d5b8ccec2dae6ac0c53833c84e677/ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da", size = 100326, upload-time = "2024-10-20T10:12:42.967Z" }, 49 | { url = "https://files.pythonhosted.org/packages/3a/65/fa39d74db4e2d0cd252355732d966a460a41cd01c6353b820a0952432839/ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28", size = 118079, upload-time = "2024-10-20T10:12:44.117Z" }, 50 | { url = "https://files.pythonhosted.org/packages/fb/8f/683c6ad562f558cbc4f7c029abcd9599148c51c54b5ef0f24f2638da9fbb/ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6", size = 132224, upload-time = "2024-10-20T10:12:45.162Z" }, 51 | { url = "https://files.pythonhosted.org/packages/3c/d2/b79b7d695e2f21da020bd44c782490578f300dd44f0a4c57a92575758a76/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:d84318609196d6bd6da0edfa25cedfbabd8dbde5140a0a23af29ad4b8f91fb1e", size = 641480, upload-time = "2024-10-20T10:12:46.758Z" }, 52 | { url = "https://files.pythonhosted.org/packages/68/6e/264c50ce2a31473a9fdbf4fa66ca9b2b17c7455b31ef585462343818bd6c/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb43a269eb827806502c7c8efb7ae7e9e9d0573257a46e8e952f4d4caba4f31e", size = 739068, upload-time = "2024-10-20T10:12:48.605Z" }, 53 | { url = "https://files.pythonhosted.org/packages/86/29/88c2567bc893c84d88b4c48027367c3562ae69121d568e8a3f3a8d363f4d/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52", size = 703012, upload-time = "2024-10-20T10:12:51.124Z" }, 54 | { url = "https://files.pythonhosted.org/packages/11/46/879763c619b5470820f0cd6ca97d134771e502776bc2b844d2adb6e37753/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642", size = 704352, upload-time = "2024-10-21T11:26:41.438Z" }, 55 | { url = "https://files.pythonhosted.org/packages/02/80/ece7e6034256a4186bbe50dee28cd032d816974941a6abf6a9d65e4228a7/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2", size = 737344, upload-time = "2024-10-21T11:26:43.62Z" }, 56 | { url = "https://files.pythonhosted.org/packages/f0/ca/e4106ac7e80efbabdf4bf91d3d32fc424e41418458251712f5672eada9ce/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3", size = 714498, upload-time = "2024-12-11T19:58:15.592Z" }, 57 | { url = "https://files.pythonhosted.org/packages/67/58/b1f60a1d591b771298ffa0428237afb092c7f29ae23bad93420b1eb10703/ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4", size = 100205, upload-time = "2024-10-20T10:12:52.865Z" }, 58 | { url = "https://files.pythonhosted.org/packages/b4/4f/b52f634c9548a9291a70dfce26ca7ebce388235c93588a1068028ea23fcc/ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb", size = 118185, upload-time = "2024-10-20T10:12:54.652Z" }, 59 | { url = "https://files.pythonhosted.org/packages/48/41/e7a405afbdc26af961678474a55373e1b323605a4f5e2ddd4a80ea80f628/ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632", size = 133433, upload-time = "2024-10-20T10:12:55.657Z" }, 60 | { url = "https://files.pythonhosted.org/packages/ec/b0/b850385604334c2ce90e3ee1013bd911aedf058a934905863a6ea95e9eb4/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:943f32bc9dedb3abff9879edc134901df92cfce2c3d5c9348f172f62eb2d771d", size = 647362, upload-time = "2024-10-20T10:12:57.155Z" }, 61 | { url = "https://files.pythonhosted.org/packages/44/d0/3f68a86e006448fb6c005aee66565b9eb89014a70c491d70c08de597f8e4/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c3829bb364fdb8e0332c9931ecf57d9be3519241323c5274bd82f709cebc0c", size = 754118, upload-time = "2024-10-20T10:12:58.501Z" }, 62 | { url = "https://files.pythonhosted.org/packages/52/a9/d39f3c5ada0a3bb2870d7db41901125dbe2434fa4f12ca8c5b83a42d7c53/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd", size = 706497, upload-time = "2024-10-20T10:13:00.211Z" }, 63 | { url = "https://files.pythonhosted.org/packages/b0/fa/097e38135dadd9ac25aecf2a54be17ddf6e4c23e43d538492a90ab3d71c6/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31", size = 698042, upload-time = "2024-10-21T11:26:46.038Z" }, 64 | { url = "https://files.pythonhosted.org/packages/ec/d5/a659ca6f503b9379b930f13bc6b130c9f176469b73b9834296822a83a132/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680", size = 745831, upload-time = "2024-10-21T11:26:47.487Z" }, 65 | { url = "https://files.pythonhosted.org/packages/db/5d/36619b61ffa2429eeaefaab4f3374666adf36ad8ac6330d855848d7d36fd/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d", size = 715692, upload-time = "2024-12-11T19:58:17.252Z" }, 66 | { url = "https://files.pythonhosted.org/packages/b1/82/85cb92f15a4231c89b95dfe08b09eb6adca929ef7df7e17ab59902b6f589/ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5", size = 98777, upload-time = "2024-10-20T10:13:01.395Z" }, 67 | { url = "https://files.pythonhosted.org/packages/d7/8f/c3654f6f1ddb75daf3922c3d8fc6005b1ab56671ad56ffb874d908bfa668/ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4", size = 115523, upload-time = "2024-10-20T10:13:02.768Z" }, 68 | { url = "https://files.pythonhosted.org/packages/29/00/4864119668d71a5fa45678f380b5923ff410701565821925c69780356ffa/ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a", size = 132011, upload-time = "2024-10-20T10:13:04.377Z" }, 69 | { url = "https://files.pythonhosted.org/packages/7f/5e/212f473a93ae78c669ffa0cb051e3fee1139cb2d385d2ae1653d64281507/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:e7e3736715fbf53e9be2a79eb4db68e4ed857017344d697e8b9749444ae57475", size = 642488, upload-time = "2024-10-20T10:13:05.906Z" }, 70 | { url = "https://files.pythonhosted.org/packages/1f/8f/ecfbe2123ade605c49ef769788f79c38ddb1c8fa81e01f4dbf5cf1a44b16/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7e75b4965e1d4690e93021adfcecccbca7d61c7bddd8e22406ef2ff20d74ef", size = 745066, upload-time = "2024-10-20T10:13:07.26Z" }, 71 | { url = "https://files.pythonhosted.org/packages/e2/a9/28f60726d29dfc01b8decdb385de4ced2ced9faeb37a847bd5cf26836815/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6", size = 701785, upload-time = "2024-10-20T10:13:08.504Z" }, 72 | { url = "https://files.pythonhosted.org/packages/84/7e/8e7ec45920daa7f76046578e4f677a3215fe8f18ee30a9cb7627a19d9b4c/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf", size = 693017, upload-time = "2024-10-21T11:26:48.866Z" }, 73 | { url = "https://files.pythonhosted.org/packages/c5/b3/d650eaade4ca225f02a648321e1ab835b9d361c60d51150bac49063b83fa/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1", size = 741270, upload-time = "2024-10-21T11:26:50.213Z" }, 74 | { url = "https://files.pythonhosted.org/packages/87/b8/01c29b924dcbbed75cc45b30c30d565d763b9c4d540545a0eeecffb8f09c/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01", size = 709059, upload-time = "2024-12-11T19:58:18.846Z" }, 75 | { url = "https://files.pythonhosted.org/packages/30/8c/ed73f047a73638257aa9377ad356bea4d96125b305c34a28766f4445cc0f/ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6", size = 98583, upload-time = "2024-10-20T10:13:09.658Z" }, 76 | { url = "https://files.pythonhosted.org/packages/b0/85/e8e751d8791564dd333d5d9a4eab0a7a115f7e349595417fd50ecae3395c/ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3", size = 115190, upload-time = "2024-10-20T10:13:10.66Z" }, 77 | ] 78 | 79 | [[package]] 80 | name = "ruff" 81 | version = "0.12.0" 82 | source = { registry = "https://pypi.org/simple" } 83 | sdist = { url = "https://files.pythonhosted.org/packages/24/90/5255432602c0b196a0da6720f6f76b93eb50baef46d3c9b0025e2f9acbf3/ruff-0.12.0.tar.gz", hash = "sha256:4d047db3662418d4a848a3fdbfaf17488b34b62f527ed6f10cb8afd78135bc5c", size = 4376101, upload-time = "2025-06-17T15:19:26.217Z" } 84 | wheels = [ 85 | { url = "https://files.pythonhosted.org/packages/e6/fd/b46bb20e14b11ff49dbc74c61de352e0dc07fb650189513631f6fb5fc69f/ruff-0.12.0-py3-none-linux_armv6l.whl", hash = "sha256:5652a9ecdb308a1754d96a68827755f28d5dfb416b06f60fd9e13f26191a8848", size = 10311554, upload-time = "2025-06-17T15:18:45.792Z" }, 86 | { url = "https://files.pythonhosted.org/packages/e7/d3/021dde5a988fa3e25d2468d1dadeea0ae89dc4bc67d0140c6e68818a12a1/ruff-0.12.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:05ed0c914fabc602fc1f3b42c53aa219e5736cb030cdd85640c32dbc73da74a6", size = 11118435, upload-time = "2025-06-17T15:18:49.064Z" }, 87 | { url = "https://files.pythonhosted.org/packages/07/a2/01a5acf495265c667686ec418f19fd5c32bcc326d4c79ac28824aecd6a32/ruff-0.12.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:07a7aa9b69ac3fcfda3c507916d5d1bca10821fe3797d46bad10f2c6de1edda0", size = 10466010, upload-time = "2025-06-17T15:18:51.341Z" }, 88 | { url = "https://files.pythonhosted.org/packages/4c/57/7caf31dd947d72e7aa06c60ecb19c135cad871a0a8a251723088132ce801/ruff-0.12.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7731c3eec50af71597243bace7ec6104616ca56dda2b99c89935fe926bdcd48", size = 10661366, upload-time = "2025-06-17T15:18:53.29Z" }, 89 | { url = "https://files.pythonhosted.org/packages/e9/ba/aa393b972a782b4bc9ea121e0e358a18981980856190d7d2b6187f63e03a/ruff-0.12.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:952d0630eae628250ab1c70a7fffb641b03e6b4a2d3f3ec6c1d19b4ab6c6c807", size = 10173492, upload-time = "2025-06-17T15:18:55.262Z" }, 90 | { url = "https://files.pythonhosted.org/packages/d7/50/9349ee777614bc3062fc6b038503a59b2034d09dd259daf8192f56c06720/ruff-0.12.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c021f04ea06966b02614d442e94071781c424ab8e02ec7af2f037b4c1e01cc82", size = 11761739, upload-time = "2025-06-17T15:18:58.906Z" }, 91 | { url = "https://files.pythonhosted.org/packages/04/8f/ad459de67c70ec112e2ba7206841c8f4eb340a03ee6a5cabc159fe558b8e/ruff-0.12.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d235618283718ee2fe14db07f954f9b2423700919dc688eacf3f8797a11315c", size = 12537098, upload-time = "2025-06-17T15:19:01.316Z" }, 92 | { url = "https://files.pythonhosted.org/packages/ed/50/15ad9c80ebd3c4819f5bd8883e57329f538704ed57bac680d95cb6627527/ruff-0.12.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c0758038f81beec8cc52ca22de9685b8ae7f7cc18c013ec2050012862cc9165", size = 12154122, upload-time = "2025-06-17T15:19:03.727Z" }, 93 | { url = "https://files.pythonhosted.org/packages/76/e6/79b91e41bc8cc3e78ee95c87093c6cacfa275c786e53c9b11b9358026b3d/ruff-0.12.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:139b3d28027987b78fc8d6cfb61165447bdf3740e650b7c480744873688808c2", size = 11363374, upload-time = "2025-06-17T15:19:05.875Z" }, 94 | { url = "https://files.pythonhosted.org/packages/db/c3/82b292ff8a561850934549aa9dc39e2c4e783ab3c21debe55a495ddf7827/ruff-0.12.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68853e8517b17bba004152aebd9dd77d5213e503a5f2789395b25f26acac0da4", size = 11587647, upload-time = "2025-06-17T15:19:08.246Z" }, 95 | { url = "https://files.pythonhosted.org/packages/2b/42/d5760d742669f285909de1bbf50289baccb647b53e99b8a3b4f7ce1b2001/ruff-0.12.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3a9512af224b9ac4757f7010843771da6b2b0935a9e5e76bb407caa901a1a514", size = 10527284, upload-time = "2025-06-17T15:19:10.37Z" }, 96 | { url = "https://files.pythonhosted.org/packages/19/f6/fcee9935f25a8a8bba4adbae62495c39ef281256693962c2159e8b284c5f/ruff-0.12.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b08df3d96db798e5beb488d4df03011874aff919a97dcc2dd8539bb2be5d6a88", size = 10158609, upload-time = "2025-06-17T15:19:12.286Z" }, 97 | { url = "https://files.pythonhosted.org/packages/37/fb/057febf0eea07b9384787bfe197e8b3384aa05faa0d6bd844b94ceb29945/ruff-0.12.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6a315992297a7435a66259073681bb0d8647a826b7a6de45c6934b2ca3a9ed51", size = 11141462, upload-time = "2025-06-17T15:19:15.195Z" }, 98 | { url = "https://files.pythonhosted.org/packages/10/7c/1be8571011585914b9d23c95b15d07eec2d2303e94a03df58294bc9274d4/ruff-0.12.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e55e44e770e061f55a7dbc6e9aed47feea07731d809a3710feda2262d2d4d8a", size = 11641616, upload-time = "2025-06-17T15:19:17.6Z" }, 99 | { url = "https://files.pythonhosted.org/packages/6a/ef/b960ab4818f90ff59e571d03c3f992828d4683561095e80f9ef31f3d58b7/ruff-0.12.0-py3-none-win32.whl", hash = "sha256:7162a4c816f8d1555eb195c46ae0bd819834d2a3f18f98cc63819a7b46f474fb", size = 10525289, upload-time = "2025-06-17T15:19:19.688Z" }, 100 | { url = "https://files.pythonhosted.org/packages/34/93/8b16034d493ef958a500f17cda3496c63a537ce9d5a6479feec9558f1695/ruff-0.12.0-py3-none-win_amd64.whl", hash = "sha256:d00b7a157b8fb6d3827b49d3324da34a1e3f93492c1f97b08e222ad7e9b291e0", size = 11598311, upload-time = "2025-06-17T15:19:21.785Z" }, 101 | { url = "https://files.pythonhosted.org/packages/d0/33/4d3e79e4a84533d6cd526bfb42c020a23256ae5e4265d858bd1287831f7d/ruff-0.12.0-py3-none-win_arm64.whl", hash = "sha256:8cd24580405ad8c1cc64d61725bca091d6b6da7eb3d36f72cc605467069d7e8b", size = 10724946, upload-time = "2025-06-17T15:19:23.952Z" }, 102 | ] 103 | --------------------------------------------------------------------------------