├── netbox_gitlab ├── migrations │ ├── __init__.py │ ├── 0001_initial.py │ └── 0002_traceelement.py ├── templates │ └── netbox_gitlab │ │ ├── error.html │ │ ├── diff_highlight_line.html │ │ ├── export_inventory.html │ │ ├── device │ │ ├── device_info.html │ │ └── update_interface_table.html │ │ ├── export_device.html │ │ ├── export_interfaces.html │ │ └── ask_branch.html ├── navigation.py ├── urls.py ├── hacks.py ├── fields.py ├── forms.py ├── static │ └── netbox_gitlab │ │ └── style.css ├── signals.py ├── __init__.py ├── models.py ├── template_content.py ├── utils.py └── views.py ├── pyproject.toml ├── setup.py ├── .idea ├── misc.xml ├── inspectionProfiles │ ├── profiles_settings.xml │ └── Project_Default.xml ├── modules.xml └── netbox-gitlab.iml ├── .github └── workflows │ └── python-publish.yml ├── setup.cfg ├── examples ├── inventory_template.j2 ├── interface_template.j2 └── device_template.j2 ├── README.md ├── .gitignore └── LICENSE.txt /netbox_gitlab/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 41.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import setuptools 4 | 5 | if __name__ == "__main__": 6 | setuptools.setup() 7 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /netbox_gitlab/templates/netbox_gitlab/error.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | GitLab export 4 |
5 | 6 | 9 |
10 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /netbox_gitlab/navigation.py: -------------------------------------------------------------------------------- 1 | from extras.plugins import PluginMenuItem 2 | 3 | menu_items = ( 4 | PluginMenuItem( 5 | link='plugins:netbox_gitlab:export-inventory', 6 | link_text='Export inventory', 7 | permissions=[ 8 | 'netbox_gitlab.export_device', 9 | ] 10 | ), 11 | ) 12 | -------------------------------------------------------------------------------- /netbox_gitlab/templates/netbox_gitlab/diff_highlight_line.html: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /netbox_gitlab/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from netbox_gitlab.views import ExportDeviceView, ExportInterfacesView, ExportInventoryView 4 | 5 | urlpatterns = [ 6 | path(route='export-inventory/', 7 | view=ExportInventoryView.as_view(), 8 | name='export-inventory'), 9 | path(route='export-device//', 10 | view=ExportDeviceView.as_view(), 11 | name='export-device'), 12 | path(route='export-interfaces//', 13 | view=ExportInterfacesView.as_view(), 14 | name='export-interfaces'), 15 | ] 16 | -------------------------------------------------------------------------------- /netbox_gitlab/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-05-25 09:53 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | initial = True 8 | 9 | dependencies = [ 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='PermissionSupport', 15 | fields=[ 16 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), 17 | ], 18 | options={ 19 | 'permissions': (('export_device', 'Can export device to GitLab'), 20 | ('export_interface', 'Can export interface to GitLab')), 21 | 'managed': False, 22 | }, 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /netbox_gitlab/hacks.py: -------------------------------------------------------------------------------- 1 | # This file contains some ugly hacks to help with some export templates 2 | # Don't do this at home 3 | 4 | from django.db.models import Q 5 | 6 | from dcim.models import Device 7 | from ipam.models import VLAN, VRF 8 | 9 | 10 | def vlans(self): 11 | device = self 12 | 13 | tagged_vlan_ids = list(device.vc_interfaces.values_list('untagged_vlan', flat=True)) 14 | untagged_vlan_ids = list(device.vc_interfaces.values_list('tagged_vlans', flat=True)) 15 | 16 | return VLAN.objects.filter(Q(id__in=tagged_vlan_ids) | Q(id__in=untagged_vlan_ids)) 17 | 18 | 19 | def vrfs(self): 20 | device = self 21 | 22 | vrf_ids = list(device.vc_interfaces.values_list('ip_addresses__vrf', flat=True)) 23 | return VRF.objects.filter(id__in=vrf_ids) 24 | 25 | 26 | # Add the vlans and vrfs properties to the Device class 27 | Device.vlans = property(vlans) 28 | Device.vrfs = property(vrfs) 29 | -------------------------------------------------------------------------------- /netbox_gitlab/fields.py: -------------------------------------------------------------------------------- 1 | from django.forms import RegexField 2 | 3 | 4 | class HTML5RegexField(RegexField): 5 | def __init__(self, regex, **kwargs): 6 | super().__init__(regex, **kwargs) 7 | 8 | # Reapply attrs 9 | extra_attrs = self.widget_attrs(self.widget) 10 | if extra_attrs: 11 | self.widget.attrs.update(extra_attrs) 12 | 13 | def widget_attrs(self, widget): 14 | attrs = super().widget_attrs(widget) 15 | if hasattr(self, '_regex'): 16 | attrs['pattern'] = self._regex.pattern 17 | return attrs 18 | 19 | 20 | class BranchNameField(HTML5RegexField): 21 | def __init__(self, **kwargs): 22 | params = { 23 | 'label': 'GitLab Branch', 24 | 'regex': '[a-z0-9]+([_-][a-z0-9]+)*', 25 | 'min_length': 3, 26 | 'max_length': 50, 27 | 'help_text': 'Choose an existing branch or create a new one' 28 | } 29 | params.update(**kwargs) 30 | super().__init__(**params) 31 | -------------------------------------------------------------------------------- /netbox_gitlab/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from dcim.models import Interface 4 | from netbox_gitlab.fields import BranchNameField 5 | from utilities.forms import BootstrapMixin 6 | 7 | 8 | class GitLabBranchForm(BootstrapMixin, forms.Form): 9 | branch = BranchNameField() 10 | 11 | 12 | class GitLabCommitInventoryForm(BootstrapMixin, forms.Form): 13 | branch = BranchNameField() 14 | update = forms.CharField() 15 | 16 | 17 | class GitLabCommitDeviceForm(BootstrapMixin, forms.Form): 18 | branch = BranchNameField() 19 | update = forms.CharField() 20 | 21 | 22 | class GitLabBranchInterfacesForm(BootstrapMixin, forms.Form): 23 | pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), required=False) 24 | 25 | branch = BranchNameField() 26 | 27 | 28 | class GitLabCommitInterfacesForm(BootstrapMixin, forms.Form): 29 | pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), required=False) 30 | 31 | branch = BranchNameField() 32 | update = forms.CharField() 33 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | push: 8 | tags: 9 | - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 10 | 11 | jobs: 12 | deploy: 13 | 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Set up Python 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: '3.x' 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install --upgrade setuptools wheel twine 26 | - name: Build and publish 27 | env: 28 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 29 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 30 | run: | 31 | python setup.py sdist bdist_wheel 32 | twine upload dist/* 33 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = netbox_gitlab 3 | version = attr: netbox_gitlab.VERSION 4 | description = GitLab export to Ansible Inventory for NetBox 5 | long_description = file: README.md 6 | long_description_content_type = text/markdown 7 | author = Sander Steffann 8 | author_email = sander.steffann@isp.solcon.nl 9 | url = https://github.com/solcon/netbox-gitlab 10 | license = Apache 2.0 11 | license_file = LICENSE.txt 12 | classifiers = 13 | Development Status :: 3 - Alpha 14 | Framework :: Django 15 | Framework :: Django :: 3.0 16 | License :: OSI Approved :: Apache Software License 17 | Programming Language :: Python :: 3 18 | Programming Language :: Python :: 3.6 19 | Programming Language :: Python :: 3.7 20 | Programming Language :: Python :: 3.8 21 | 22 | [options] 23 | zip_safe = False 24 | packages = find: 25 | python_requires = >=3.6 26 | install_requires = 27 | setuptools 28 | python-gitlab==1.15.0 29 | 30 | [options.package_data] 31 | netbox_gitlab = 32 | static/netbox_gitlab/*.css 33 | templates/*.html 34 | templates/*/*.html 35 | templates/*/*/*.html 36 | templates/*/*/*/*.html 37 | -------------------------------------------------------------------------------- /netbox_gitlab/static/netbox_gitlab/style.css: -------------------------------------------------------------------------------- 1 | td.gitlab { 2 | text-align: center; 3 | } 4 | 5 | div.gitlab_diff table.diff { 6 | width: 100%; 7 | } 8 | 9 | div.gitlab_diff table.diff td { 10 | font-family: "JetBrains Mono", Menlo, Consolas, "Andale Mono", "Lucida Console", monospace; 11 | padding-left: 10px; 12 | } 13 | 14 | div.gitlab_diff th.diff_next, 15 | div.gitlab_diff td.diff_next { 16 | display: none; 17 | } 18 | 19 | div.gitlab_diff th.diff_header { 20 | padding-left: 4px; 21 | } 22 | 23 | div.gitlab_diff td.diff_header { 24 | color: lightgray; 25 | } 26 | 27 | div.gitlab_diff span.diff_add, 28 | div.gitlab_diff span.diff_sub, 29 | div.gitlab_diff span.diff_chg { 30 | border-radius: 8px; 31 | padding: 2px 3px; 32 | margin: -2px -1px; 33 | background-color: #f0f0f0; 34 | } 35 | 36 | div.gitlab_diff span.diff_add, 37 | div.gitlab_diff td.diff_header.diff_add { 38 | color: green; 39 | } 40 | 41 | div.gitlab_diff span.diff_sub, 42 | div.gitlab_diff td.diff_header.diff_sub { 43 | color: #cc0000; 44 | } 45 | 46 | div.gitlab_diff span.diff_chg, 47 | div.gitlab_diff td.diff_header.diff_chg { 48 | color: darkorange; 49 | } 50 | -------------------------------------------------------------------------------- /.idea/netbox-gitlab.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | -------------------------------------------------------------------------------- /netbox_gitlab/signals.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.db.models.signals import post_save, pre_delete 4 | from django.dispatch import receiver 5 | 6 | from circuits.models import CircuitTermination 7 | from dcim.models import Cable, FrontPort, Interface, RearPort 8 | from netbox_gitlab.models import TraceElement 9 | from netbox_gitlab.utils import update_trace_cache 10 | 11 | logger = logging.getLogger('netbox_gitlab') 12 | 13 | 14 | @receiver(pre_delete, sender=Cable) 15 | @receiver(pre_delete, sender=Interface) 16 | @receiver(pre_delete, sender=FrontPort) 17 | @receiver(pre_delete, sender=RearPort) 18 | @receiver(pre_delete, sender=CircuitTermination) 19 | def delete_affected_traces(instance, **_kwargs): 20 | """ 21 | When an element is deleted then delete all traces that contain it. They can be re-generated later. 22 | """ 23 | for trace_element in TraceElement.objects.filter(element=instance): 24 | # Delete all trace elements on this path 25 | TraceElement.objects.filter(from_interface=trace_element.from_interface).delete() 26 | 27 | 28 | @receiver(post_save, sender=Cable) 29 | def update_connected_endpoints(instance, **_kwargs): 30 | """ 31 | When a Cable is saved, update the trace cache for all its endpoints 32 | """ 33 | # Update any endpoints for this Cable. 34 | endpoints = instance.termination_a.get_path_endpoints() + instance.termination_b.get_path_endpoints() 35 | for endpoint in endpoints: 36 | if isinstance(endpoint, Interface): 37 | update_trace_cache(endpoint) 38 | 39 | 40 | @receiver(post_save, sender=Interface) 41 | def update_trace(instance, **_kwargs): 42 | """ 43 | When an Interface is saved and the connection_status is None this can indicate a deleted cable. Rebuild the 44 | trace cache. May be optimised later. 45 | """ 46 | if instance.connection_status is None: 47 | update_trace_cache(instance) 48 | -------------------------------------------------------------------------------- /netbox_gitlab/templates/netbox_gitlab/export_inventory.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% load form_helpers %} 4 | {% load buttons %} 5 | {% load static %} 6 | 7 | {% block header %} 8 | 9 |
10 |
11 | 14 |
15 |
16 |

{% block title %}GitLab - Export Inventory{% endblock %}

17 |

18 | Exporting to GitLab branch {{ branch }}: 19 |

20 | {% endblock %} 21 | 22 | {% block content %} 23 | {% if form.non_field_errors %} 24 |
25 |
Errors
26 |
27 | {{ form.non_field_errors }} 28 |
29 |
30 | {% endif %} 31 | 32 |
33 |
34 |
35 |
36 | {{ diff }} 37 |
38 |
39 |
40 |
41 | 42 |
43 | {% csrf_token %} 44 | 45 | {# Resubmit our original input in case the POST can't be processed #} 46 | {{ form.branch.as_hidden }} 47 | 48 | {# The full new data to be committed, so that nothing can change after viewing the changes #} 49 | {{ form.update.as_hidden }} 50 | 51 |
52 |
53 | 54 | Cancel 55 |
56 |
57 |
58 | 59 | {% include 'netbox_gitlab/diff_highlight_line.html' %} 60 | {% endblock %} 61 | -------------------------------------------------------------------------------- /netbox_gitlab/templates/netbox_gitlab/device/device_info.html: -------------------------------------------------------------------------------- 1 | {% load helpers %} 2 | {% load static %} 3 | 4 | 5 | 6 | {# Create the dialog boxes #} 7 | {% if device_changes %} 8 | 11 | 12 | 23 | 24 | {% include 'netbox_gitlab/diff_highlight_line.html' %} 25 | {% endif %} 26 | 27 |
28 |
29 | GitLab export 30 |
31 | 32 |
33 | {% if empty %} 34 | 36 | Device not in GitLab 37 | {% elif device_changes %} 38 | 40 | Changed fields: 41 |
    42 | {% for field in device_changes.fields %} 43 |
  • {{ field }}
  • 44 | {% endfor %} 45 |
46 | {% else %} 47 | 48 | Export is in sync. 49 | {% endif %} 50 |
51 | 52 | {% if perms.netbox_gitlab.export_device %} 53 | 58 | {% endif %} 59 |
60 | -------------------------------------------------------------------------------- /examples/inventory_template.j2: -------------------------------------------------------------------------------- 1 | # GENERATED BY NETBOX 2 | # Changes will be overwritten 3 | 4 | {% set manageable_devices = queryset 5 | .filter(device_role__slug__in=['ar', 'cr', 'crs', 'drs', 'oob']) 6 | .exclude(site__slug__in=['labdro3']) 7 | .exclude(primary_ip4=None, primary_ip6=None) 8 | .order_by('site__slug', 'name') 9 | -%} 10 | {% set extra_tags = manageable_devices 11 | .filter(tags__slug__startswith='role-') 12 | .values_list('tags__slug', flat=True) 13 | .order_by('tags__slug') 14 | .distinct() 15 | -%} 16 | {% set manageable_device_names = manageable_devices.values_list('name', flat=True) -%} 17 | {% set member_devices = manageable_devices 18 | .values_list('virtual_chassis__members__name', flat=True) 19 | .order_by('virtual_chassis__members__name') 20 | -%} 21 | 22 | # Grouping by platform 23 | {% for group, devices in manageable_devices | selectattr('platform') | groupby('platform.slug') -%} 24 | [{{ group | replace('-', '_') }}] 25 | {% for device in devices -%} 26 | {{ device.name }} 27 | {% endfor %} 28 | {% endfor %} 29 | 30 | # Grouping by device type 31 | {% for group, devices in manageable_devices | selectattr('device_type') | groupby('device_type.manufacturer.slug') -%} 32 | {% for subgroup, subdevices in devices | groupby('device_type.slug') -%} 33 | [{{ group | replace('-', '_') }}_{{ subgroup | replace('-', '_') }}] 34 | {% for device in subdevices -%} 35 | {{ device.name }} 36 | {% endfor %} 37 | {% endfor %}{% endfor %} 38 | 39 | # Grouping by site 40 | {% for group, devices in manageable_devices | selectattr('site') | groupby('site.slug') -%} 41 | [{{ group | replace('-', '_') }}] 42 | {% for device in devices -%} 43 | {{ device.name }} 44 | {% endfor %} 45 | {% endfor %} 46 | 47 | # Grouping by device role 48 | {% for group, devices in manageable_devices | selectattr('device_role') | groupby('device_role.slug') -%} 49 | [{{ group | replace('-', '_') }}] 50 | {% for device in devices -%} 51 | {{ device.name }} 52 | {% endfor %} 53 | {% endfor %} 54 | 55 | # Grouping by role tag 56 | {% for group in extra_tags -%} 57 | [{{ group | replace('role-', '', 1) | replace('-', '_') }}] 58 | {% for device in manageable_devices.filter(tags__slug=group).distinct() -%} 59 | {{ device.name }} 60 | {% endfor %} 61 | {% endfor %} 62 | 63 | # All devices without a management address 64 | [unmanaged] 65 | {% for device_name in member_devices if device_name and device_name not in manageable_device_names -%} 66 | {{ device_name }} 67 | {% endfor %} 68 | -------------------------------------------------------------------------------- /netbox_gitlab/migrations/0002_traceelement.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-06-03 18:20 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ('circuits', '0018_standardize_description'), 10 | ('dcim', '0105_interface_name_collation'), 11 | ('netbox_gitlab', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='TraceElement', 17 | fields=[ 18 | ('id', models.AutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False) 22 | ), 23 | ('step', models.PositiveIntegerField()), 24 | ('_cable', models.ForeignKey( 25 | blank=True, 26 | null=True, 27 | on_delete=django.db.models.deletion.CASCADE, 28 | related_name='+', 29 | to='dcim.Cable') 30 | ), 31 | ('_circuit_termination', models.ForeignKey( 32 | blank=True, 33 | null=True, 34 | on_delete=django.db.models.deletion.CASCADE, 35 | related_name='+', 36 | to='circuits.CircuitTermination') 37 | ), 38 | ('_front_port', models.ForeignKey( 39 | blank=True, 40 | null=True, 41 | on_delete=django.db.models.deletion.CASCADE, 42 | related_name='+', 43 | to='dcim.FrontPort') 44 | ), 45 | ('_interface', models.ForeignKey( 46 | blank=True, 47 | null=True, 48 | on_delete=django.db.models.deletion.CASCADE, 49 | related_name='+', 50 | to='dcim.Interface') 51 | ), 52 | ('_rear_port', models.ForeignKey( 53 | blank=True, 54 | null=True, 55 | on_delete=django.db.models.deletion.CASCADE, 56 | related_name='+', 57 | to='dcim.RearPort') 58 | ), 59 | ('from_interface', models.ForeignKey( 60 | on_delete=django.db.models.deletion.CASCADE, 61 | related_name='trace_elements', 62 | to='dcim.Interface') 63 | ), 64 | ], 65 | options={ 66 | 'verbose_name': 'trace element', 67 | 'verbose_name_plural': 'trace elements', 68 | 'ordering': ('from_interface_id', 'step'), 69 | 'unique_together': {('from_interface', 'step')}, 70 | }, 71 | ), 72 | ] 73 | -------------------------------------------------------------------------------- /netbox_gitlab/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = '0.6.1' 2 | 3 | try: 4 | from extras.plugins import PluginConfig 5 | except ImportError: 6 | # Dummy for when importing outside of netbox 7 | class PluginConfig: 8 | pass 9 | 10 | 11 | class NetBoxGitLabConfig(PluginConfig): 12 | name = 'netbox_gitlab' 13 | verbose_name = 'GitLab Ansible export' 14 | version = VERSION 15 | author = 'Sander Steffann' 16 | author_email = 'sander.steffann@isp.solcon.nl' 17 | description = 'GitLab export to Ansible Inventory for NetBox' 18 | base_url = 'gitlab' 19 | required_settings = [ 20 | 'url', 21 | 'private_token', 22 | 'project_path', 23 | ] 24 | default_settings = { 25 | 'main_branch': 'main', 26 | 'ssl_verify': True, 27 | 28 | 'inventory_file': 'hosts.ini', 29 | 'device_file': 'host_vars/{device.name}/generated-device.yaml', 30 | 'interfaces_file': 'host_vars/{device.name}/generated-interfaces.yaml', 31 | 32 | 'inventory_template': 'Ansible Inventory', 33 | 'devices_template': 'Ansible Device', 34 | 'interfaces_template': 'Ansible Interfaces', 35 | 'interfaces_key': 'interfaces', 36 | 37 | 'device_prefetch': [ 38 | 'interfaces__ip_addresses', 39 | 'interfaces__ip_addresses__tags', 40 | 'interfaces__ip_addresses__vrf', 41 | ], 42 | 'interfaces_prefetch': [ 43 | 'device', 44 | 'tags', 45 | 'lag', 46 | 'ip_addresses', 47 | 'ip_addresses__tags', 48 | 'ip_addresses__vrf', 49 | 'untagged_vlan', 50 | 'tagged_vlans', 51 | '_connected_interface', 52 | '_connected_interface__device', 53 | '_connected_circuittermination', 54 | '_connected_circuittermination__circuit', 55 | '_connected_circuittermination__circuit__provider', 56 | '_connected_circuittermination__circuit__type', 57 | 'trace_elements', 58 | 'trace_elements___cable', 59 | 'trace_elements___cable__termination_a_type', 60 | 'trace_elements___cable__termination_b_type', 61 | 'trace_elements___front_port', 62 | 'trace_elements___front_port__device', 63 | 'trace_elements___rear_port', 64 | 'trace_elements___rear_port__device', 65 | 'trace_elements___interface', 66 | 'trace_elements___interface__device', 67 | 'trace_elements___circuit_termination', 68 | 'trace_elements___circuit_termination__circuit', 69 | 'trace_elements___circuit_termination__circuit__type', 70 | 'trace_elements___circuit_termination__circuit__provider', 71 | ], 72 | } 73 | 74 | def ready(self): 75 | super().ready() 76 | 77 | from . import hacks 78 | from . import signals 79 | 80 | 81 | config = NetBoxGitLabConfig 82 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitLab export to Ansible Inventory for NetBox 2 | 3 | ## Compatibility 4 | 5 | This plugin in compatible with [NetBox](https://netbox.readthedocs.org/) 2.8 and later. 6 | 7 | ## Installation 8 | 9 | First, add `netbox_gitlab` to your `/opt/netbox/local_requirements.txt` file. Create it if it doesn't exist. 10 | 11 | If you are using a local version of the plugin, for example for development, add `-e /opt/path/to/plugin` instead. 12 | 13 | Then enable the plugin in `/opt/netbox/netbox/netbox/configuration.py`, like: 14 | 15 | ```python 16 | PLUGINS = [ 17 | 'netbox_gitlab', 18 | ] 19 | ``` 20 | 21 | The plugin needs to be configured. The following settings are required: 22 | 23 | ```python 24 | PLUGINS_CONFIG = { 25 | 'netbox_gitlab': { 26 | 'url': 'https://gitlab.example.com', 27 | 'private_token': 'aBcDeFgHiJkLmNoPqRsTuVwXyZ', 28 | 'project_path': 'group/project', 29 | }, 30 | } 31 | ``` 32 | 33 | This example would correspond to the project at `https://gitlab.example.com/group/project`. 34 | 35 | And finally run `/opt/netbox/upgrade.sh`. This will download and install the plugin and update the database when 36 | necessary. Don't forget to run `sudo systemctl restart netbox netbox-rq` like `upgrade.sh` tells you! 37 | 38 | ## Usage 39 | 40 | This plugin uses NetBox export templates to generate the files that are put into the git repository. The output of these templates is parsed as YAML, but JSON output is also accepted (as all valid JSON is also valid YAML). Generating JSON can be more convenient because of the more relaxed parsing of indentation. 41 | 42 | By default this plugin looks for these export templates: 43 | 44 | | Content type | Name | Purpose | 45 | |------------------|--------------------|-------------------------------------------------------| 46 | | dcim > device | Ansible Inventory | A single file listing all devices | 47 | | dcim > device | Ansible Device | One file per device with device-level configuration | 48 | | dcim > interface | Ansible Interfaces | One file per device with its interface configurations | 49 | 50 | The output sent to GitLab for the inventory is exactly what the export template produces. The output sent to GitLab for device and interface configurations is always YAML with all the empty variables omitted. This makes it easier when writing export templates while still keeping the output compact. This in turn helps to keep Ansible a bit faster by reducing the time spent on parsing the YAML. 51 | 52 | For the devices export template this plugin expects the generated YAML/JSON to be a mapping with the device name as the key. When a device is part of a virtual chassis all members of the virtual chassis will be included. 53 | 54 | For the interfaces export template this plugin expects the generated YAML/JSON to be a mapping with the interface name as the key. 55 | 56 | Examples of export templates are provided in the git repository of this plugin. 57 | -------------------------------------------------------------------------------- /netbox_gitlab/templates/netbox_gitlab/export_device.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% load form_helpers %} 4 | {% load buttons %} 5 | {% load static %} 6 | 7 | {% block header %} 8 | 9 | 10 |
11 |
12 | 27 |
28 |
29 |

{% block title %}GitLab - Export Device{% endblock %}

30 |

31 | Exporting to GitLab branch {{ branch }}: 32 |

33 | {% endblock %} 34 | 35 | {% block content %} 36 | {% if form.non_field_errors %} 37 |
38 |
Errors
39 |
40 | {{ form.non_field_errors }} 41 |
42 |
43 | {% endif %} 44 | 45 | {% for device, diff in diffs.items %} 46 |
47 |
48 |
49 |
{{ device }}
50 |
51 | {{ diff }} 52 |
53 |
54 |
55 |
56 | {% endfor %} 57 | 58 |
59 | {% csrf_token %} 60 | 61 | {# Resubmit our original input in case the POST can't be processed #} 62 | {{ form.branch.as_hidden }} 63 | 64 | {# The full new data to be committed, so that nothing can change after viewing the changes #} 65 | {{ form.update.as_hidden }} 66 | 67 |
68 |
69 | 70 | Cancel 71 |
72 |
73 |
74 | 75 | {% include 'netbox_gitlab/diff_highlight_line.html' %} 76 | {% endblock %} 77 | -------------------------------------------------------------------------------- /netbox_gitlab/templates/netbox_gitlab/export_interfaces.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% load form_helpers %} 4 | {% load buttons %} 5 | {% load static %} 6 | 7 | {% block header %} 8 | 9 | 10 |
11 |
12 | 27 |
28 |
29 |

{% block title %}GitLab - Export Interfaces{% endblock %}

30 |

31 | Exporting to GitLab branch {{ branch }}: 32 |

33 | {% endblock %} 34 | 35 | {% block content %} 36 | {% if form.non_field_errors %} 37 |
38 |
Errors
39 |
40 | {{ form.non_field_errors }} 41 |
42 |
43 | {% endif %} 44 | 45 | {% for device, diff in diffs.items %} 46 |
47 |
48 |
49 |
{{ device }}
50 |
51 | {{ diff }} 52 |
53 |
54 |
55 |
56 | {% endfor %} 57 | 58 |
59 | {% csrf_token %} 60 | 61 | {# Resubmit our original input in case the POST can't be processed #} 62 | {{ form.pk.as_hidden }} 63 | {{ form.branch.as_hidden }} 64 | 65 | {# The full new data to be committed, so that nothing can change after viewing the changes #} 66 | {{ form.update.as_hidden }} 67 | 68 |
69 |
70 | 71 | Cancel 72 |
73 |
74 |
75 | 76 | {% include 'netbox_gitlab/diff_highlight_line.html' %} 77 | {% endblock %} 78 | -------------------------------------------------------------------------------- /netbox_gitlab/templates/netbox_gitlab/ask_branch.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% load form_helpers %} 4 | {% load buttons %} 5 | {% load static %} 6 | 7 | {% block header %} 8 | 9 | 10 |
11 |
12 | 29 |
30 |
31 |

{% block title %}GitLab - Export {{ export_type }}{% endblock %}

32 | {% endblock %} 33 | 34 | {% block content %} 35 | {% if form.non_field_errors %} 36 |
37 |
Errors
38 |
39 | {{ form.non_field_errors }} 40 |
41 |
42 | {% endif %} 43 | 44 |
45 | {% csrf_token %} 46 | 47 | {# Resubmit our original input in case the POST can't be processed #} 48 | {{ form.pk.as_hidden }} 49 | 50 |
51 |
52 |
53 |
GitLab
54 |
55 | {% render_field form.branch %} 56 |
57 |
58 |
59 |
60 | 61 |
62 |
63 | 64 | Cancel 65 |
66 |
67 |
68 | 69 | 84 | 85 | {% include 'netbox_gitlab/diff_highlight_line.html' %} 86 | {% endblock %} 87 | -------------------------------------------------------------------------------- /netbox_gitlab/templates/netbox_gitlab/device/update_interface_table.html: -------------------------------------------------------------------------------- 1 | {% load helpers %} 2 | {% load static %} 3 | 4 | 5 | 6 | {# Create the dialog boxes #} 7 | {% for name,changes in interface_changes.items %} 8 | {% if changes %} 9 | 12 | {% endif %} 13 | {% endfor %} 14 | 15 | {% include 'netbox_gitlab/diff_highlight_line.html' %} 16 | 17 | 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### JetBrains template 3 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 4 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 5 | 6 | # User-specific stuff 7 | .idea/**/workspace.xml 8 | .idea/**/tasks.xml 9 | .idea/**/usage.statistics.xml 10 | .idea/**/dictionaries 11 | .idea/**/shelf 12 | 13 | # Generated files 14 | .idea/**/contentModel.xml 15 | 16 | # Sensitive or high-churn files 17 | .idea/**/dataSources/ 18 | .idea/**/dataSources.ids 19 | .idea/**/dataSources.local.xml 20 | .idea/**/sqlDataSources.xml 21 | .idea/**/dynamic.xml 22 | .idea/**/uiDesigner.xml 23 | .idea/**/dbnavigator.xml 24 | 25 | # Gradle 26 | .idea/**/gradle.xml 27 | .idea/**/libraries 28 | 29 | # Gradle and Maven with auto-import 30 | # When using Gradle or Maven with auto-import, you should exclude module files, 31 | # since they will be recreated, and may cause churn. Uncomment if using 32 | # auto-import. 33 | # .idea/artifacts 34 | # .idea/compiler.xml 35 | # .idea/modules.xml 36 | # .idea/*.iml 37 | # .idea/modules 38 | # *.iml 39 | # *.ipr 40 | 41 | # CMake 42 | cmake-build-*/ 43 | 44 | # Mongo Explorer plugin 45 | .idea/**/mongoSettings.xml 46 | 47 | # File-based project format 48 | *.iws 49 | 50 | # IntelliJ 51 | out/ 52 | 53 | # mpeltonen/sbt-idea plugin 54 | .idea_modules/ 55 | 56 | # JIRA plugin 57 | atlassian-ide-plugin.xml 58 | 59 | # Cursive Clojure plugin 60 | .idea/replstate.xml 61 | 62 | # Crashlytics plugin (for Android Studio and IntelliJ) 63 | com_crashlytics_export_strings.xml 64 | crashlytics.properties 65 | crashlytics-build.properties 66 | fabric.properties 67 | 68 | # Editor-based Rest Client 69 | .idea/httpRequests 70 | 71 | # Android studio 3.1+ serialized cache file 72 | .idea/caches/build_file_checksums.ser 73 | 74 | ### Python template 75 | # Byte-compiled / optimized / DLL files 76 | __pycache__/ 77 | *.py[cod] 78 | *$py.class 79 | 80 | # C extensions 81 | *.so 82 | 83 | # Distribution / packaging 84 | .Python 85 | build/ 86 | develop-eggs/ 87 | dist/ 88 | downloads/ 89 | eggs/ 90 | .eggs/ 91 | lib/ 92 | lib64/ 93 | parts/ 94 | sdist/ 95 | var/ 96 | wheels/ 97 | pip-wheel-metadata/ 98 | share/python-wheels/ 99 | *.egg-info/ 100 | .installed.cfg 101 | *.egg 102 | MANIFEST 103 | 104 | # PyInstaller 105 | # Usually these files are written by a python script from a template 106 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 107 | *.manifest 108 | *.spec 109 | 110 | # Installer logs 111 | pip-log.txt 112 | pip-delete-this-directory.txt 113 | 114 | # Unit test / coverage reports 115 | htmlcov/ 116 | .tox/ 117 | .nox/ 118 | .coverage 119 | .coverage.* 120 | .cache 121 | nosetests.xml 122 | coverage.xml 123 | *.cover 124 | *.py,cover 125 | .hypothesis/ 126 | .pytest_cache/ 127 | 128 | # Translations 129 | *.mo 130 | *.pot 131 | 132 | # Django stuff: 133 | *.log 134 | local_settings.py 135 | db.sqlite3 136 | db.sqlite3-journal 137 | 138 | # Flask stuff: 139 | instance/ 140 | .webassets-cache 141 | 142 | # Scrapy stuff: 143 | .scrapy 144 | 145 | # Sphinx documentation 146 | docs/_build/ 147 | 148 | # PyBuilder 149 | target/ 150 | 151 | # Jupyter Notebook 152 | .ipynb_checkpoints 153 | 154 | # IPython 155 | profile_default/ 156 | ipython_config.py 157 | 158 | # pyenv 159 | .python-version 160 | 161 | # pipenv 162 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 163 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 164 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 165 | # install all needed dependencies. 166 | #Pipfile.lock 167 | 168 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 169 | __pypackages__/ 170 | 171 | # Celery stuff 172 | celerybeat-schedule 173 | celerybeat.pid 174 | 175 | # SageMath parsed files 176 | *.sage.py 177 | 178 | # Environments 179 | .env 180 | .venv 181 | env/ 182 | venv/ 183 | ENV/ 184 | env.bak/ 185 | venv.bak/ 186 | 187 | # Spyder project settings 188 | .spyderproject 189 | .spyproject 190 | 191 | # Rope project settings 192 | .ropeproject 193 | 194 | # mkdocs documentation 195 | /site 196 | 197 | # mypy 198 | .mypy_cache/ 199 | .dmypy.json 200 | dmypy.json 201 | 202 | # Pyre type checker 203 | .pyre/ 204 | 205 | -------------------------------------------------------------------------------- /netbox_gitlab/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from circuits.models import CircuitTermination 5 | from dcim.models import Cable, FrontPort, Interface, RearPort 6 | 7 | 8 | class PermissionSupport(models.Model): 9 | class Meta: 10 | # No database table creation or deletion operations will be performed for this model. 11 | managed = False 12 | 13 | permissions = ( 14 | ('export_device', 'Can export device to GitLab'), 15 | ('export_interface', 'Can export interface to GitLab'), 16 | ) 17 | 18 | 19 | class TraceElementQuerySet(models.QuerySet): 20 | def _filter_or_exclude(self, negate, *args, **kwargs): 21 | # Handle filtering on element 22 | if 'element' in kwargs: 23 | element = kwargs.pop('element') 24 | if element is None: 25 | kwargs['_cable'] = None 26 | kwargs['_interface'] = None 27 | kwargs['_front_port'] = None 28 | kwargs['_rear_port'] = None 29 | kwargs['_circuit_termination'] = None 30 | elif isinstance(element, Cable): 31 | kwargs['_cable'] = element 32 | elif isinstance(element, Interface): 33 | kwargs['_interface'] = element 34 | elif isinstance(element, FrontPort): 35 | kwargs['_front_port'] = element 36 | elif isinstance(element, RearPort): 37 | kwargs['_rear_port'] = element 38 | elif isinstance(element, CircuitTermination): 39 | kwargs['_circuit_termination'] = element 40 | else: 41 | raise ValueError("unsupported element type") 42 | 43 | return super()._filter_or_exclude(negate, *args, **kwargs) 44 | 45 | 46 | class TraceElement(models.Model): 47 | from_interface = models.ForeignKey( 48 | verbose_name=_('from interface'), 49 | to=Interface, 50 | on_delete=models.CASCADE, 51 | related_name='trace_elements', 52 | ) 53 | step = models.PositiveIntegerField( 54 | verbose_name=_('step'), 55 | ) 56 | 57 | _cable = models.ForeignKey( 58 | to=Cable, 59 | on_delete=models.CASCADE, 60 | related_name='+', 61 | blank=True, 62 | null=True, 63 | ) 64 | _interface = models.ForeignKey( 65 | to=Interface, 66 | on_delete=models.CASCADE, 67 | related_name='+', 68 | blank=True, 69 | null=True, 70 | ) 71 | _front_port = models.ForeignKey( 72 | to=FrontPort, 73 | on_delete=models.CASCADE, 74 | related_name='+', 75 | blank=True, 76 | null=True, 77 | ) 78 | _rear_port = models.ForeignKey( 79 | to=RearPort, 80 | on_delete=models.CASCADE, 81 | related_name='+', 82 | blank=True, 83 | null=True, 84 | ) 85 | _circuit_termination = models.ForeignKey( 86 | to=CircuitTermination, 87 | on_delete=models.CASCADE, 88 | related_name='+', 89 | blank=True, 90 | null=True, 91 | ) 92 | 93 | objects = TraceElementQuerySet.as_manager() 94 | 95 | class Meta: 96 | ordering = ('from_interface_id', 'step') 97 | unique_together = [ 98 | ('from_interface', 'step'), 99 | ] 100 | verbose_name = _('trace element') 101 | verbose_name_plural = _('trace elements') 102 | 103 | def __str__(self): 104 | return f"{self.from_interface}[{self.step}]: {self.element}" 105 | 106 | @property 107 | def element_type(self): 108 | if self._cable_id: 109 | return 'cable' 110 | elif self._interface_id: 111 | return 'interface' 112 | elif self._front_port_id: 113 | return 'front_port' 114 | elif self._rear_port_id: 115 | return 'rear_port' 116 | elif self._circuit_termination_id: 117 | return 'circuit_termination' 118 | else: 119 | return None 120 | 121 | @property 122 | def element(self): 123 | if self._cable_id: 124 | return self._cable 125 | elif self._interface_id: 126 | return self._interface 127 | elif self._front_port_id: 128 | return self._front_port 129 | elif self._rear_port_id: 130 | return self._rear_port 131 | elif self._circuit_termination_id: 132 | return self._circuit_termination 133 | else: 134 | return None 135 | 136 | @element.setter 137 | def element(self, value): 138 | self._cable = None 139 | self._interface = None 140 | self._front_port = None 141 | self._rear_port = None 142 | self._circuit_termination = None 143 | 144 | if isinstance(value, Cable): 145 | self._cable = value 146 | elif isinstance(value, Interface): 147 | self._interface = value 148 | elif isinstance(value, FrontPort): 149 | self._front_port = value 150 | elif isinstance(value, RearPort): 151 | self._rear_port = value 152 | elif isinstance(value, CircuitTermination): 153 | self._circuit_termination = value 154 | -------------------------------------------------------------------------------- /examples/interface_template.j2: -------------------------------------------------------------------------------- 1 | {% macro extract_endpoint(element) %} 2 | {% if element.device %} 3 | "device": "{{ element.device.name }}", 4 | "interface": "{{ element.name }}", 5 | {% endif %} 6 | "circuit": { 7 | {% if element.circuit %} 8 | "cid": "{{ element.circuit.cid }}", 9 | "description": "{{ element.circuit.description }}", 10 | "status": "{{ element.circuit.get_status_display() }}", 11 | {% if element.circuit.provider %} 12 | "provider": { 13 | "name": "{{ element.circuit.provider.name }}", 14 | "slug": "{{ element.circuit.provider.slug }}" 15 | }, 16 | {% endif %} 17 | "type": { 18 | "name": "{{ element.circuit.type.name }}", 19 | "slug": "{{ element.circuit.type.slug }}" 20 | } 21 | {% endif %} 22 | } 23 | {% endmacro %} 24 | 25 | { 26 | "interfaces": { 27 | {% for interface in queryset %} 28 | "{{ interface.name }}": { 29 | "name": "{{ interface.name }}", 30 | "physical_device": "{{ interface.device.name }}", 31 | "enabled": {{ "true" if interface.enabled else "false" }}, 32 | "mtu": {{ interface.mtu or "null" }}, 33 | "mac_address": "{{ interface.mac_address or "" }}", 34 | "description": "{{ interface.description }}", 35 | {% if interface.mgmt_only %} 36 | "mgmt_only": true, 37 | {% endif %} 38 | 39 | "tags": [ 40 | {% for tag in interface.tags.order_by('slug') %} 41 | "{{ tag.name }}", 42 | {% endfor %} 43 | ], 44 | "tag_slugs": [ 45 | {% for tag in interface.tags.order_by('slug') %} 46 | "{{ tag.slug }}", 47 | {% endfor %} 48 | ], 49 | 50 | "form_factor": "{{ interface.get_type_display() }}", 51 | "form_factor_value": "{{ interface.type }}", 52 | {% if interface.connection_status is not none %} 53 | "connection_status": "{{ interface.get_connection_status_display() }}", 54 | "connection_status_value": {{ 'true' if interface.connection_status else 'false' }}, 55 | {% endif %} 56 | "mode": "{{ interface.get_mode_display() }}", 57 | "mode_value": "{{ interface.mode }}", 58 | 59 | {% if interface.lag is not none %} 60 | "lag": "{{ interface.lag.name }}", 61 | {% endif %} 62 | 63 | "ip_addresses": [ 64 | {% for ip_address in interface.ip_addresses.all() %} 65 | { 66 | "address": "{{ ip_address.address }}", 67 | "family": "IPv{{ ip_address.family }}", 68 | "family_value": {{ ip_address.family }}, 69 | "description": "{{ ip_address.description }}", 70 | {% if ip_address.vrf is not none %} 71 | "vrf": "{{ ip_address.vrf.name }}", 72 | "vrf_rd": "{{ ip_address.vrf.rd }}", 73 | {% endif %} 74 | "status": "{{ ip_address.get_status_display() }}", 75 | "status_value": "{{ ip_address.status }}", 76 | "role": "{{ ip_address.get_role_display() }}", 77 | "role_value": "{{ ip_address.role }}", 78 | 79 | "tags": [ 80 | {% for tag in ip_address.tags.order_by('slug') %} 81 | "{{ tag.name }}", 82 | {% endfor %} 83 | ], 84 | "tag_slugs": [ 85 | {% for tag in ip_address.tags.order_by('slug') %} 86 | "{{ tag.slug }}", 87 | {% endfor %} 88 | ] 89 | }, 90 | {% endfor %} 91 | ], 92 | 93 | {% if interface.untagged_vlan is not none %} 94 | "untagged_vlan": { 95 | "name": "{{ interface.untagged_vlan.name }}", 96 | "vid": {{ interface.untagged_vlan.vid }} 97 | }, 98 | {% endif %} 99 | 100 | "tagged_vlans": [ 101 | {% for vlan in interface.tagged_vlans.all() %} 102 | { 103 | "name": "{{ vlan.name }}", 104 | "vid": {{ vlan.vid }} 105 | }, 106 | {% endfor %} 107 | ], 108 | 109 | "connected_endpoint": { 110 | {% if interface.connected_endpoint %} 111 | {{ extract_endpoint(interface.connected_endpoint) }}, 112 | "via": [ 113 | {% for item in interface.trace_elements.all() %} 114 | {% if not loop.first and not loop.last and item.element_type != 'cable' %} 115 | { 116 | {{ extract_endpoint(item.element) }} 117 | }, 118 | {% endif %} 119 | {% endfor %} 120 | ] 121 | {% endif %} 122 | } 123 | }, 124 | {% endfor %} 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /netbox_gitlab/template_content.py: -------------------------------------------------------------------------------- 1 | from difflib import HtmlDiff 2 | 3 | import yaml 4 | from django.contrib.auth.context_processors import PermWrapper 5 | from django.http import HttpRequest 6 | from django.utils.functional import cached_property 7 | from django.utils.safestring import mark_safe 8 | from yaml.parser import ParserError 9 | 10 | from dcim.models import Device 11 | from extras.plugins import PluginTemplateExtension 12 | from netbox_gitlab.utils import (GitLabDumper, GitLabMixin, dict_changes, expand_virtual_chassis, extract_interfaces, 13 | generate_device_interfaces, generate_devices) 14 | 15 | 16 | # noinspection PyAbstractClass 17 | class SyncInfo(GitLabMixin, PluginTemplateExtension): 18 | model = 'dcim.device' 19 | 20 | def __init__(self, context): 21 | super().__init__(context) 22 | 23 | self.device = self.context['object'] # type: Device 24 | self.request = self.context['request'] # type: HttpRequest 25 | 26 | @cached_property 27 | def device_data(self): 28 | # Still called "master" to maintain consistency with NetBox 29 | master, devices = expand_virtual_chassis(self.device) 30 | netbox_devices = generate_devices(devices) 31 | 32 | return devices, netbox_devices 33 | 34 | def left_page(self): 35 | if not self.project: 36 | return '' 37 | 38 | try: 39 | # Skip interfaces if device template returns False 40 | devices, netbox_devices = self.device_data 41 | if isinstance(netbox_devices, dict) \ 42 | and self.device.name in netbox_devices \ 43 | and netbox_devices[self.device.name] is False: 44 | return '' 45 | 46 | branch = self.config['main_branch'] 47 | gitlab_interfaces_data = self.get_gitlab_interfaces(branch, self.device) 48 | gitlab_interfaces = extract_interfaces(gitlab_interfaces_data) or {} 49 | 50 | netbox_interfaces_data = generate_device_interfaces(self.device) 51 | netbox_interfaces = extract_interfaces(netbox_interfaces_data) 52 | if not netbox_interfaces: 53 | return '' 54 | 55 | differ = HtmlDiff(tabsize=2) 56 | 57 | interface_changes = {} 58 | for name, data in netbox_interfaces.items(): 59 | gitlab_interface = gitlab_interfaces.get(name, {}) 60 | netbox_interface = netbox_interfaces.get(name, {}) 61 | 62 | changes = dict_changes(netbox_interface, gitlab_interface) 63 | if changes: 64 | gitlab_yaml = yaml.dump(gitlab_interface, Dumper=GitLabDumper, default_flow_style=False) 65 | netbox_yaml = yaml.dump(netbox_interface, Dumper=GitLabDumper, default_flow_style=False) 66 | 67 | interface_changes[name] = { 68 | 'fields': changes, 69 | 'diff': mark_safe(differ.make_table( 70 | fromdesc='GitLab', 71 | fromlines=gitlab_yaml.splitlines(), 72 | todesc='NetBox', 73 | tolines=netbox_yaml.splitlines(), 74 | )), 75 | 'gitlab_yaml': gitlab_yaml, 76 | 'gitlab_empty': not gitlab_interface, 77 | 'netbox_yaml': netbox_yaml, 78 | 'netbox_empty': not netbox_interface, 79 | } 80 | else: 81 | interface_changes[name] = None 82 | 83 | return self.render('netbox_gitlab/device/update_interface_table.html', { 84 | 'interface_changes': interface_changes, 85 | 'device': devices[0], 86 | 'perms': PermWrapper(self.request.user), 87 | }) 88 | except ParserError: 89 | return self.render('netbox_gitlab/error.html', { 90 | 'message': "Error in the YAML/JSON generated by the export template", 91 | }) 92 | except: 93 | return self.render('netbox_gitlab/error.html', { 94 | 'message': "Unexpected error while processing export data", 95 | }) 96 | 97 | def right_page(self): 98 | if not self.project: 99 | if self.gitlab_error: 100 | return self.render('netbox_gitlab/error.html', { 101 | 'message': "GitLab server error: " + self.gitlab_error 102 | }) 103 | else: 104 | return '' 105 | 106 | try: 107 | # Skip if device template returns False 108 | devices, netbox_devices = self.device_data 109 | if not isinstance(netbox_devices, dict) \ 110 | or self.device.name not in netbox_devices \ 111 | or netbox_devices[self.device.name] is False: 112 | return '' 113 | 114 | branch = self.config['main_branch'] 115 | gitlab_devices = {device.name: self.get_gitlab_device(branch, device) 116 | for device in devices} 117 | 118 | # Get the main device 119 | gitlab_device = gitlab_devices.get(self.device.name, {}) 120 | netbox_device = netbox_devices.get(self.device.name, {}) 121 | 122 | differ = HtmlDiff(tabsize=2) 123 | 124 | changes = dict_changes(gitlab_device, netbox_device) 125 | if changes: 126 | gitlab_yaml = yaml.dump(gitlab_devices, Dumper=GitLabDumper, default_flow_style=False) 127 | netbox_yaml = yaml.dump(netbox_devices, Dumper=GitLabDumper, default_flow_style=False) 128 | 129 | device_changes = { 130 | 'fields': changes, 131 | 'diff': mark_safe(differ.make_table( 132 | fromdesc='GitLab', 133 | fromlines=gitlab_yaml.splitlines(), 134 | todesc='NetBox', 135 | tolines=netbox_yaml.splitlines(), 136 | )), 137 | 'gitlab_yaml': gitlab_yaml, 138 | 'netbox_yaml': netbox_yaml, 139 | } 140 | else: 141 | device_changes = None 142 | 143 | return self.render('netbox_gitlab/device/device_info.html', { 144 | 'empty': not gitlab_device, 145 | 'device_changes': device_changes, 146 | 'perms': PermWrapper(self.request.user), 147 | }) 148 | except ParserError: 149 | return self.render('netbox_gitlab/error.html', { 150 | 'message': "Error in the YAML/JSON generated by the export template", 151 | }) 152 | except: 153 | return self.render('netbox_gitlab/error.html', { 154 | 'message': "Unexpected error while processing export data", 155 | }) 156 | 157 | 158 | template_extensions = [SyncInfo] 159 | -------------------------------------------------------------------------------- /examples/device_template.j2: -------------------------------------------------------------------------------- 1 | { 2 | {% for device in queryset %} 3 | {# Still using "master" here to maintain consistency with NetBox #} 4 | {% set master_device = device.virtual_chassis.master if device.virtual_chassis else device %} 5 | {% if master_device.platform.slug|default('') not in ['ios', 'junos'] or master_device.device_role.slug not in ['ar', 'cr', 'crs', 'drs', 'oob'] %} 6 | {# Returning false explicitly disables processing interfaces, which can be a big performance win #} 7 | "{{ device.name }}": false, 8 | {% else %} 9 | "{{ device.name }}": { 10 | {% if device.primary_ip %} 11 | "ansible_host": "{{ device.primary_ip.address.ip }}", 12 | {% endif %} 13 | 14 | {% set ansible_user=device.custom_field_values.filter(field__name='management_user').first() %} 15 | {% if ansible_user %} 16 | "ansible_user": "{{ ansible_user.value }}", 17 | {% endif %} 18 | 19 | {% if device.platform.slug | default(None) == 'junos' %} 20 | "ansible_connection": "netconf", 21 | "ansible_network_os": "junos", 22 | {% elif device.platform.slug | default(None) == 'ios' %} 23 | "ansible_connection": "network_cli", 24 | "ansible_network_os": "ios", 25 | "ansible_become": true, 26 | "ansible_become_method": "enable", 27 | {% endif %} 28 | 29 | "status": "{{ device.get_status_display() }}", 30 | "status_value": "{{ device.status }}", 31 | 32 | "role": "{{ device.device_role.name }}", 33 | "role_slug": "{{ device.device_role.slug }}", 34 | 35 | "manufacturer": "{{ device.device_type.manufacturer.name }}", 36 | "manufacturer_slug": "{{ device.device_type.manufacturer.slug }}", 37 | 38 | "device_type": "{{ device.device_type.model }}", 39 | "device_type_slug": "{{ device.device_type.slug }}", 40 | "device_type_display_name": "{{ device.device_type.display_name }}", 41 | 42 | "platform": "{{ device.platform.name | default('') }}", 43 | "platform_slug": "{{ device.platform.slug | default('') }}", 44 | 45 | "site": "{{ device.site.name }}", 46 | "site_slug": "{{ device.site.slug }}", 47 | 48 | "tenant": "{{ device.tenant.name | default('') }}", 49 | "tenant_slug": "{{ device.tenant.slug |default ('') }}", 50 | 51 | "rack_name": "{{ device.rack.name | default('') }}", 52 | "rack_display_name": "{{ device.rack.display_name | default('') }}", 53 | 54 | "rack_face": "{{ device.get_face_display() }}", 55 | "rack_face_value": "{{ device.face }}", 56 | 57 | "rack_position": {{ device.position if device.position is not none else 'null' }}, 58 | 59 | {% if device.virtual_chassis %} 60 | "virtual_chassis": {{ device.virtual_chassis.id }}, 61 | "virtual_chassis_master": {{ device.virtual_chassis.master.id }}, 62 | "virtual_chassis_master_name": "{{ device.virtual_chassis.master.name }}", 63 | "virtual_chassis_position": {{ device.vc_position if device.vc_position is not none else 'null' }}, 64 | "virtual_chassis_priority": {{ device.vc_priority if device.vc_priority is not none else 'null' }}, 65 | 66 | {% if device.id == device.virtual_chassis.master.id %} 67 | "virtual_chassis_members": [ 68 | {% for member in device.virtual_chassis.members.exclude(id=device.id).order_by('vc_position') %} 69 | { 70 | "device": "{{ member.name }}", 71 | "device_type": "{{ member.device_type.model }}", 72 | "position": {{ member.vc_position if member.vc_position is not none else 'null' }}, 73 | }, 74 | {% endfor %} 75 | ], 76 | {% endif %} 77 | {% endif %} 78 | 79 | "tags": [ 80 | {% for tag in device.tags.order_by('slug') %} 81 | "{{ tag.name }}", 82 | {% endfor %} 83 | ], 84 | "tag_slugs": [ 85 | {% for tag in device.tags.order_by('slug') %} 86 | "{{ tag.slug }}", 87 | {% endfor %} 88 | ], 89 | 90 | {% set ospf_area = device.custom_field_values.filter(field__name='ospf_area').first() %} 91 | {% if ospf_area %} 92 | "ospf_area": "{{ ospf_area.value }}", 93 | {% endif %} 94 | 95 | "vrfs": [ 96 | {% for vrf in device.vrfs %} 97 | { 98 | "name": "{{ vrf.name }}", 99 | "rd": "{{ vrf.rd }}", 100 | "description": "{{ vrf.description }}" 101 | } 102 | {% endfor %} 103 | ], 104 | 105 | "vlans": [ 106 | {% for vlan in device.vlans %} 107 | { 108 | "name": "{{ vlan.name }}", 109 | "vid": {{ vlan.vid }}, 110 | "description": "{{ vlan.description }}", 111 | "tags": [ 112 | {% for tag in vlan.tags.order_by('slug') %} 113 | "{{ tag.name }}", 114 | {% endfor %} 115 | ], 116 | "tag_slugs": [ 117 | {% for tag in vlan.tags.order_by('slug') %} 118 | "{{ tag.slug }}", 119 | {% endfor %} 120 | ], 121 | {% if vlan.site %} 122 | "site": "{{ vlan.site.name }}", 123 | "site_slug": "{{ vlan.site.slug }}", 124 | {% endif %} 125 | {% if vlan.group %} 126 | "group": "{{ vlan.group.name }}", 127 | "group_slug": "{{ vlan.group.slug }}", 128 | {% endif %} 129 | "status": "{{ vlan.get_status_display() }}", 130 | "status_value": "{{ vlan.status }}", 131 | {% if vlan.role %} 132 | "role": "{{ vlan.role.name }}", 133 | "role_slug": "{{ vlan.role.slug }}", 134 | {% endif %} 135 | {% if vlan.tenant %} 136 | "tenant": "{{ vlan.tenant.name }}", 137 | "tenant_slug": "{{ vlan.tenant.slug }}", 138 | {% endif %} 139 | }, 140 | {% endfor %} 141 | ] 142 | }, 143 | {% endif %} 144 | {% endfor %} 145 | } 146 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /netbox_gitlab/utils.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import itertools 3 | from difflib import HtmlDiff 4 | from typing import Any, Dict, Iterable, List, Optional, Tuple 5 | 6 | import yaml 7 | from django.conf import settings 8 | from django.contrib.contenttypes.models import ContentType 9 | from django.db import transaction 10 | from django.db.models import QuerySet 11 | from django.utils.safestring import mark_safe 12 | from gitlab import Gitlab, GitlabCreateError, GitlabError, GitlabGetError, GitlabHttpError 13 | from gitlab.utils import clean_str_id 14 | from gitlab.v4.objects import ProjectMergeRequest 15 | from yaml import SafeDumper, SafeLoader 16 | 17 | from dcim.models import Device, Interface 18 | from extras.models import ExportTemplate 19 | from netbox_gitlab.models import TraceElement 20 | 21 | 22 | class GitLabDumper(SafeDumper): 23 | pass 24 | 25 | 26 | # noinspection PyAbstractClass 27 | class GitLabLoader(SafeLoader): 28 | pass 29 | 30 | 31 | def clean_value(value): 32 | if isinstance(value, dict): 33 | value = clean_dict(value) 34 | elif isinstance(value, list): 35 | clean_values = [clean_value(item) for item in value] 36 | value = [] 37 | for item in clean_values: 38 | if item in value: 39 | continue 40 | value.append(item) 41 | 42 | if value in ('', [], {}): 43 | return None 44 | 45 | return value 46 | 47 | 48 | def clean_dict(data: Dict[str, Any]) -> dict: 49 | out = {} 50 | 51 | for key, value in data.items(): 52 | value = clean_value(value) 53 | if value is None: 54 | continue 55 | 56 | out[key] = value 57 | 58 | return out 59 | 60 | 61 | def generate_inventory() -> Optional[str]: 62 | config = settings.PLUGINS_CONFIG['netbox_gitlab'] 63 | devices_template = config['inventory_template'] 64 | try: 65 | device_model_ct = ContentType.objects.get_for_model(Device) 66 | export_template = ExportTemplate.objects.get(content_type=device_model_ct, 67 | name=devices_template) 68 | 69 | # Re-fetch device with prefetch 70 | devices = Device.objects.prefetch_related( 71 | *settings.PLUGINS_CONFIG['netbox_gitlab']['device_prefetch'] 72 | ) 73 | output = export_template.render(devices) 74 | except ExportTemplate.DoesNotExist: 75 | return 76 | 77 | return output 78 | 79 | 80 | def generate_devices(devices: Iterable[Device]): 81 | config = settings.PLUGINS_CONFIG['netbox_gitlab'] 82 | devices_template = config['devices_template'] 83 | try: 84 | device_model_ct = ContentType.objects.get_for_model(Device) 85 | export_template = ExportTemplate.objects.get(content_type=device_model_ct, 86 | name=devices_template) 87 | 88 | # Re-fetch device with prefetch 89 | device = Device.objects.filter(pk__in=[device.pk for device in devices]).prefetch_related( 90 | *settings.PLUGINS_CONFIG['netbox_gitlab']['device_prefetch'] 91 | ) 92 | output = export_template.render(device) 93 | except ExportTemplate.DoesNotExist: 94 | return 95 | 96 | # Parse as YAML 97 | data = yaml.load(output, Loader=GitLabLoader) 98 | if data: 99 | data = clean_value(data) 100 | 101 | return data 102 | 103 | 104 | def generate_device_interfaces(device: Device): 105 | generate_missing_traces(device.vc_interfaces.all()) 106 | interfaces = device.vc_interfaces.prefetch_related( 107 | *settings.PLUGINS_CONFIG['netbox_gitlab']['interfaces_prefetch'] 108 | ) 109 | return generate_interfaces(interfaces) 110 | 111 | 112 | def generate_interfaces(interfaces: Iterable[Interface]): 113 | config = settings.PLUGINS_CONFIG['netbox_gitlab'] 114 | interfaces_template = config['interfaces_template'] 115 | try: 116 | interface_model_ct = ContentType.objects.get_for_model(Interface) 117 | export_template = ExportTemplate.objects.get(content_type=interface_model_ct, 118 | name=interfaces_template) 119 | output = export_template.render(interfaces) 120 | except ExportTemplate.DoesNotExist: 121 | return 122 | 123 | # Parse as YAML 124 | data = yaml.load(output, Loader=GitLabLoader) 125 | if data: 126 | data = clean_value(data) 127 | 128 | return data 129 | 130 | 131 | def extract_interfaces(data) -> Optional[dict]: 132 | config = settings.PLUGINS_CONFIG['netbox_gitlab'] 133 | key = config['interfaces_key'] 134 | if isinstance(data, dict) and key in data and isinstance(data[key], dict): 135 | return data[key] 136 | 137 | return {} 138 | 139 | 140 | def expand_virtual_chassis(device: Device) -> Tuple[Optional[Device], List[Device]]: 141 | devices = [] 142 | 143 | # Start with a single device, and assume it's its own master 144 | # Still called "master" to maintain consistency with NetBox 145 | master = device 146 | devices.append(device) 147 | 148 | # Add children of virtual chassis and determine the real master 149 | if device.virtual_chassis: 150 | master = device.virtual_chassis.master 151 | for child in device.virtual_chassis.members.all(): 152 | if child not in devices: 153 | devices.append(child) 154 | 155 | return master, devices 156 | 157 | 158 | def update_trace_cache(interface: Interface): 159 | path = interface.trace()[0] 160 | elements = [TraceElement(from_interface=interface, step=step, element=element) 161 | for step, element in enumerate(itertools.chain(*path)) 162 | if element is not None] 163 | 164 | with transaction.atomic(): 165 | TraceElement.objects.filter(from_interface=interface).delete() 166 | TraceElement.objects.bulk_create(elements) 167 | 168 | 169 | def generate_missing_traces(interfaces: QuerySet): 170 | existing = list(TraceElement.objects.values_list('from_interface', flat=True)) 171 | for interface in interfaces.exclude(pk__in=existing): 172 | update_trace_cache(interface) 173 | 174 | 175 | def make_diff(gitlab_data: str, netbox_data: str, differ: HtmlDiff = None) -> str: 176 | differ = differ or HtmlDiff(tabsize=2) 177 | diff = mark_safe(differ.make_table( 178 | fromdesc='GitLab', 179 | fromlines=gitlab_data.splitlines(), 180 | todesc='NetBox', 181 | tolines=netbox_data.splitlines(), 182 | )) 183 | return diff 184 | 185 | 186 | def make_diffs(devices: Iterable[Device], gitlab_data: Dict[str, Any], netbox_data: Dict[str, Any]) -> Dict[str, str]: 187 | differ = HtmlDiff(tabsize=2) 188 | diffs = {} 189 | for device in devices: 190 | gitlab_interfaces = gitlab_data[device.name] 191 | netbox_interfaces = netbox_data[device.name] 192 | 193 | if not gitlab_interfaces and not netbox_interfaces: 194 | continue 195 | 196 | old_fragment = yaml.dump(gitlab_interfaces, Dumper=GitLabDumper, default_flow_style=False) 197 | new_fragment = yaml.dump(netbox_interfaces, Dumper=GitLabDumper, default_flow_style=False) 198 | diffs[device.name] = make_diff(gitlab_data=old_fragment, 199 | netbox_data=new_fragment, 200 | differ=differ) 201 | 202 | return diffs 203 | 204 | 205 | class GitLabMixin: 206 | def __init__(self, *args, **kwargs): 207 | super().__init__(*args, **kwargs) 208 | 209 | self.config = settings.PLUGINS_CONFIG['netbox_gitlab'] 210 | 211 | try: 212 | self.gitlab = Gitlab(url=self.config['url'], 213 | private_token=self.config['private_token'], 214 | ssl_verify=self.config['ssl_verify']) 215 | self.project = self.gitlab.projects.get(id=self.config['project_path']) 216 | self.gitlab_error = None 217 | except GitlabError as e: 218 | self.gitlab = None 219 | self.project = None 220 | self.gitlab_error = e.error_message 221 | 222 | def branch_exists(self, branch: str) -> bool: 223 | try: 224 | self.project.branches.get(branch) 225 | return True 226 | except GitlabGetError as e: 227 | if e.response_code == 404: 228 | # Branch doesn't exist yet 229 | return False 230 | else: 231 | raise 232 | 233 | def get_hash_from_repo(self, branch: str, filename: str) -> Optional[str]: 234 | path = '{}/{}'.format(self.project.files.path, clean_str_id(filename)) 235 | try: 236 | file_data = self.gitlab.http_request(verb='HEAD', path=path, ref=branch) 237 | except GitlabHttpError: 238 | return None 239 | 240 | return file_data.headers['X-Gitlab-Content-Sha256'] 241 | 242 | def get_gitlab_inventory(self, branch: str) -> Optional[str]: 243 | try: 244 | file_data = self.project.files.get( 245 | file_path=self.config['inventory_file'], 246 | ref=branch, 247 | ) 248 | return file_data.decode().decode('utf-8') 249 | except GitlabError: 250 | return 251 | 252 | def get_gitlab_device(self, branch: str, device: Device): 253 | try: 254 | file_data = self.project.files.get( 255 | file_path=self.config['device_file'].format(device=device), 256 | ref=branch, 257 | ) 258 | return yaml.load(file_data.decode(), Loader=GitLabLoader) 259 | except GitlabError: 260 | return {} 261 | 262 | def get_gitlab_interfaces(self, branch: str, device: Device): 263 | try: 264 | file_data = self.project.files.get( 265 | file_path=self.config['interfaces_file'].format(device=device), 266 | ref=branch, 267 | ) 268 | return yaml.load(file_data.decode(), Loader=GitLabLoader) 269 | except GitlabError: 270 | return None 271 | 272 | 273 | class GitLabCommitMixin(GitLabMixin): 274 | def __init__(self, *args, **kwargs): 275 | super().__init__(*args, **kwargs) 276 | 277 | self.commit_files = {} 278 | 279 | def gitlab_add_file(self, filename: str, content: str): 280 | self.commit_files[filename] = content 281 | 282 | def commit(self, user, branch: str) -> Tuple[int, Optional[ProjectMergeRequest]]: 283 | base_branch = self.config['main_branch'] 284 | author_name = "{user.first_name} {user.last_name}".format(user=user).strip() or user.username 285 | author_email = user.email 286 | 287 | gitlab_data = { 288 | 'branch': branch, 289 | 'commit_message': 'Update router config from Netbox', 290 | 'author_name': author_name, 291 | 'author_email': author_email, 292 | 'actions': [] 293 | } 294 | 295 | # Determine whether we are creating a new branch or updating an existing one 296 | branch_exists = self.branch_exists(branch) 297 | if branch_exists: 298 | ref_branch = branch 299 | else: 300 | # We are creating a new branch, so use the base branch when comparing content 301 | ref_branch = base_branch 302 | gitlab_data['start_branch'] = base_branch 303 | 304 | # Build the list of updates 305 | for filename, content in self.commit_files.items(): 306 | repo_hash = self.get_hash_from_repo(ref_branch, filename) 307 | if not repo_hash: 308 | # No hash = no file, so create a new one 309 | gitlab_data['actions'].append({ 310 | 'action': 'create', 311 | 'file_path': filename, 312 | 'content': content, 313 | }) 314 | else: 315 | # Calculate the hash of the new version 316 | my_hash = hashlib.sha256(content.encode('utf8')).hexdigest() 317 | if my_hash != repo_hash: 318 | # File exists, but hash is different 319 | gitlab_data['actions'].append({ 320 | 'action': 'update', 321 | 'file_path': filename, 322 | 'content': content, 323 | }) 324 | else: 325 | # File exists and has the same hash, don't update 326 | pass 327 | 328 | if gitlab_data['actions']: 329 | self.project.commits.create(gitlab_data) 330 | 331 | # Create a merge request when we created a branch 332 | create_merge_request = not branch_exists 333 | else: 334 | # We didn't do anything, so no sense creating a merge request 335 | create_merge_request = False 336 | 337 | merge_req = None 338 | if create_merge_request: 339 | try: 340 | merge_req = self.project.mergerequests.create({ 341 | 'source_branch': branch, 342 | 'target_branch': base_branch, 343 | 'title': 'Merge Netbox updates from ' + branch, 344 | 'remove_source_branch': True, 345 | 'allow_collaboration': True, 346 | }) 347 | except GitlabCreateError as e: 348 | if e.response_code == 409: 349 | # Already exists, which is fine 350 | pass 351 | else: 352 | raise 353 | else: 354 | # See if there is a merge request for this branch 355 | merge_reqs = self.project.mergerequests.list( 356 | state='opened', 357 | source_branch=branch, 358 | target_branch=base_branch, 359 | ) 360 | if len(merge_reqs) == 1: 361 | merge_req = merge_reqs[0] 362 | 363 | return len(gitlab_data['actions']), merge_req 364 | 365 | 366 | def dict_changes(dict1: dict, dict2: dict) -> list: 367 | changes = [] 368 | for key in sorted(set(dict1.keys()) | set(dict2.keys())): 369 | dict1_value = dict1.get(key) 370 | dict2_value = dict2.get(key) 371 | 372 | # Don't bother if both values evaluate to False 373 | if not dict1_value and not dict2_value: 374 | continue 375 | 376 | if isinstance(dict1_value, dict) and isinstance(dict2_value, dict): 377 | # Both are dicts, dive in 378 | sub_changes = dict_changes(dict1_value, dict2_value) 379 | for value in sub_changes: 380 | changes.append(f'{key}.{value}') 381 | 382 | elif dict1_value != dict2_value: 383 | changes.append(key) 384 | 385 | return changes 386 | -------------------------------------------------------------------------------- /netbox_gitlab/views.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | import yaml 4 | from django.conf import settings 5 | from django.contrib import messages 6 | from django.contrib.auth.mixins import PermissionRequiredMixin 7 | from django.core import signing 8 | from django.core.signing import SignatureExpired 9 | from django.http import HttpRequest, HttpResponse 10 | from django.shortcuts import get_object_or_404, redirect, render 11 | from django.utils.safestring import mark_safe 12 | from django.views import View 13 | 14 | from dcim.models import Device, Interface 15 | from netbox_gitlab.forms import (GitLabBranchForm, GitLabBranchInterfacesForm, GitLabCommitDeviceForm, 16 | GitLabCommitInterfacesForm, 17 | GitLabCommitInventoryForm) 18 | from netbox_gitlab.utils import (GitLabCommitMixin, GitLabDumper, expand_virtual_chassis, extract_interfaces, 19 | generate_devices, generate_interfaces, generate_inventory, make_diff, make_diffs) 20 | from utilities.views import GetReturnURLMixin 21 | 22 | 23 | class ExportInventoryView(GitLabCommitMixin, PermissionRequiredMixin, GetReturnURLMixin, View): 24 | permission_required = 'netbox_gitlab.export_device' 25 | 26 | def ask_branch(self, request) -> HttpResponse: 27 | # Collect all the branches we can push to 28 | branches = [branch.name for branch in self.project.branches.list() if branch.can_push] 29 | 30 | form = GitLabBranchForm() 31 | 32 | return render(request, 'netbox_gitlab/ask_branch.html', { 33 | 'export_type': 'Inventory', 34 | 'form': form, 35 | 'branches': branches, 36 | 'return_url': self.get_return_url(request), 37 | }) 38 | 39 | def show_diff(self, request: HttpRequest, form: GitLabCommitInventoryForm = None) -> HttpResponse: 40 | # If we don't have a GitLab project we can't do anything 41 | if not self.project: 42 | messages.error(self.request, f"GitLab server error: {self.gitlab_error}") 43 | return redirect('home') 44 | 45 | branch = request.POST['branch'] \ 46 | if self.branch_exists(str(request.POST['branch'])) \ 47 | else self.config['main_branch'] 48 | gitlab_inventory = self.get_gitlab_inventory(branch) or '' 49 | netbox_inventory = generate_inventory() 50 | if not netbox_inventory: 51 | return redirect('home') 52 | 53 | diff = make_diff( 54 | gitlab_data=gitlab_inventory, 55 | netbox_data=netbox_inventory 56 | ) 57 | 58 | # Collect all the branches we can push to 59 | branches = [branch.name for branch in self.project.branches.list() if branch.can_push] 60 | 61 | update = signing.dumps(netbox_inventory, salt='netbox_gitlab.inventory', compress=True) 62 | 63 | if not form: 64 | form = GitLabCommitInventoryForm(initial={ 65 | 'update': update, 66 | }) 67 | else: 68 | # Override the bound data 69 | form.data['update'] = update 70 | 71 | return render(request, 'netbox_gitlab/export_inventory.html', { 72 | 'branch': request.POST['branch'], 73 | 'diff': diff, 74 | 'form': form, 75 | 'branches': branches, 76 | 'return_url': self.get_return_url(request), 77 | }) 78 | 79 | def get(self, request: HttpRequest) -> HttpResponse: 80 | return self.ask_branch(request) 81 | 82 | def post(self, request: HttpRequest) -> HttpResponse: 83 | # Check is branch name is valid 84 | branch_form = GitLabBranchForm(data=request.POST) 85 | if not branch_form.is_valid(): 86 | return self.ask_branch(request) 87 | 88 | form = GitLabCommitInventoryForm(data=copy.copy(request.POST)) 89 | if not form.is_valid(): 90 | return self.show_diff(request, form) 91 | 92 | try: 93 | new_gitlab_data = signing.loads(form.cleaned_data['update'], salt='netbox_gitlab.inventory', max_age=900) 94 | except SignatureExpired: 95 | messages.warning(request, "Update expired, please submit again") 96 | return self.show_diff(request, form) 97 | 98 | # We appear to have new gitlab data! 99 | filename = self.config['inventory_file'] 100 | self.gitlab_add_file(filename, new_gitlab_data) 101 | 102 | branch = form.cleaned_data['branch'] 103 | changes, merge_req = self.commit(user=self.request.user, branch=branch) 104 | 105 | if not changes: 106 | messages.warning(self.request, f"Nothing has changed (changes already committed to branch {branch}?)") 107 | elif merge_req: 108 | messages.success(self.request, mark_safe( 109 | f'Inventory changed in branch {branch}, ' 110 | f'merge request {merge_req.iid} created' 111 | )) 112 | else: 113 | messages.success(self.request, f"Inventory changed in branch {branch}") 114 | 115 | return redirect(self.get_return_url(request)) 116 | 117 | 118 | class ExportDeviceView(GitLabCommitMixin, PermissionRequiredMixin, GetReturnURLMixin, View): 119 | permission_required = 'netbox_gitlab.export_device' 120 | 121 | def ask_branch(self, request, device: Device) -> HttpResponse: 122 | # Collect all the branches we can push to 123 | branches = [branch.name for branch in self.project.branches.list() if branch.can_push] 124 | 125 | form = GitLabBranchForm() 126 | 127 | return render(request, 'netbox_gitlab/ask_branch.html', { 128 | 'export_type': 'Device', 129 | 'device': device, 130 | 'form': form, 131 | 'branches': branches, 132 | 'return_url': self.get_return_url(request, device), 133 | }) 134 | 135 | def show_diff(self, request: HttpRequest, base_device: Device, form: GitLabCommitDeviceForm = None) -> HttpResponse: 136 | # Get all the relevant devices 137 | # Still called "master" to maintain consistency with NetBox 138 | master, devices = expand_virtual_chassis(base_device) 139 | 140 | # If we don't have a GitLab project we can't do anything 141 | if not self.project: 142 | messages.error(self.request, f"GitLab server error: {self.gitlab_error}") 143 | return redirect(self.get_return_url(request, base_device)) 144 | 145 | branch = request.POST['branch'] \ 146 | if self.branch_exists(str(request.POST['branch'])) \ 147 | else self.config['main_branch'] 148 | gitlab_devices = {device.name: self.get_gitlab_device(branch, device) 149 | for device in devices} 150 | 151 | netbox_devices = generate_devices(devices) 152 | 153 | # Create the diffs for the selected devices 154 | diffs = make_diffs( 155 | devices=devices, 156 | gitlab_data=gitlab_devices, 157 | netbox_data=netbox_devices 158 | ) 159 | 160 | # Construct the new contents of the whole file 161 | new_gitlab_data = {name: yaml.dump(netbox_device, 162 | Dumper=GitLabDumper, sort_keys=False, default_flow_style=False) 163 | for name, netbox_device in netbox_devices.items()} 164 | 165 | update = signing.dumps(new_gitlab_data, salt='netbox_gitlab.devices', compress=True) 166 | 167 | if not form: 168 | form = GitLabCommitDeviceForm(initial={ 169 | 'update': update, 170 | }) 171 | else: 172 | # Override the bound data 173 | form.data['update'] = update 174 | 175 | return render(request, 'netbox_gitlab/export_device.html', { 176 | 'branch': request.POST['branch'], 177 | 'device': base_device, 178 | 'diffs': diffs, 179 | 'form': form, 180 | 'return_url': self.get_return_url(request, base_device), 181 | }) 182 | 183 | def get(self, request: HttpRequest, device_id: int) -> HttpResponse: 184 | base_device = get_object_or_404(Device, pk=device_id) 185 | return self.ask_branch(request, base_device) 186 | 187 | def post(self, request: HttpRequest, device_id: int) -> HttpResponse: 188 | base_device = get_object_or_404(Device, pk=device_id) 189 | 190 | # Check is branch name is valid 191 | branch_form = GitLabBranchForm(data=request.POST) 192 | if not branch_form.is_valid(): 193 | return self.ask_branch(request, base_device) 194 | 195 | # Check if the rest of the form is valid 196 | form = GitLabCommitDeviceForm(data=copy.copy(request.POST)) 197 | if not form.is_valid(): 198 | return self.show_diff(request, base_device, form) 199 | 200 | try: 201 | new_gitlab_data = signing.loads(form.cleaned_data['update'], salt='netbox_gitlab.devices', max_age=900) 202 | except SignatureExpired: 203 | messages.warning(request, "Update expired, please submit again") 204 | return self.show_diff(request, base_device, form) 205 | 206 | # We appear to have new gitlab data! 207 | for device_name, content in new_gitlab_data.items(): 208 | device = get_object_or_404(Device, name=device_name) 209 | filename = self.config['device_file'].format(device=device) 210 | self.gitlab_add_file(filename, content) 211 | 212 | branch = form.cleaned_data['branch'] 213 | changes, merge_req = self.commit(user=self.request.user, branch=branch) 214 | 215 | if not changes: 216 | messages.warning(self.request, f"Nothing has changed (changes already committed to branch {branch}?)") 217 | elif merge_req: 218 | messages.success(self.request, mark_safe( 219 | f'{changes} file(s) changed in branch {branch}, ' 220 | f'merge request {merge_req.iid} created' 221 | )) 222 | else: 223 | messages.success(self.request, f"{changes} file(s) changed in branch {branch}") 224 | 225 | return redirect(self.get_return_url(request, base_device)) 226 | 227 | 228 | class ExportInterfacesView(GitLabCommitMixin, PermissionRequiredMixin, GetReturnURLMixin, View): 229 | permission_required = 'netbox_gitlab.export_interface' 230 | 231 | # noinspection PyMethodMayBeStatic,PyUnusedLocal 232 | def get(self, request, device_id: int) -> HttpResponse: 233 | return redirect('home') 234 | 235 | def ask_branch(self, request, device: Device) -> HttpResponse: 236 | interface_ids = request.POST.getlist('pk') 237 | 238 | # Collect all the branches we can push to 239 | branches = [branch.name for branch in self.project.branches.list() if branch.can_push] 240 | 241 | form = GitLabBranchInterfacesForm(initial={ 242 | 'pk': interface_ids, 243 | }) 244 | 245 | return render(request, 'netbox_gitlab/ask_branch.html', { 246 | 'export_type': 'Interfaces', 247 | 'device': device, 248 | 'form': form, 249 | 'branches': branches, 250 | 'return_url': self.get_return_url(request, device), 251 | }) 252 | 253 | def show_diff(self, request, base_device: Device, form: GitLabCommitInterfacesForm = None) -> HttpResponse: 254 | interface_ids = request.POST.getlist('pk') 255 | 256 | # Get all the relevant devices 257 | master, devices = expand_virtual_chassis(base_device) 258 | 259 | # Get all the relevant interfaces 260 | interfaces = Interface.objects.filter(pk__in=interface_ids, device__in=devices).order_by('_name') 261 | if not interfaces: 262 | messages.warning(request, "No interfaces were selected for export, regenerating whole file") 263 | interfaces = Interface.objects.filter(device__in=devices).order_by('_name') 264 | regenerate = True 265 | else: 266 | regenerate = False 267 | 268 | interface_lookup = {interface.name: interface for interface in interfaces} 269 | 270 | # Prepare a new update 271 | branch = request.POST['branch'] \ 272 | if self.branch_exists(str(request.POST['branch'])) \ 273 | else self.config['main_branch'] 274 | gitlab_data = {device.name: self.get_gitlab_interfaces(branch, device) for device in devices} 275 | gitlab_interfaces = {device_name: extract_interfaces(device_interfaces) 276 | for device_name, device_interfaces in gitlab_data.items()} 277 | 278 | netbox_data = generate_interfaces(interfaces) 279 | netbox_plain_interfaces = extract_interfaces(netbox_data) 280 | if not netbox_plain_interfaces: 281 | return redirect(self.get_return_url(request, base_device)) 282 | 283 | # Extract the interfaces we are going to change from the GitLab data for the diff 284 | orig_gitlab_interfaces = {device_name: { 285 | if_name: if_data 286 | for if_name, if_data in device_interfaces.items() 287 | if regenerate or if_name in netbox_plain_interfaces 288 | } for device_name, device_interfaces in gitlab_interfaces.items()} 289 | 290 | if regenerate: 291 | gitlab_interfaces = {device.name: {} for device in devices} 292 | 293 | # Update GitLab data with new NetBox data 294 | netbox_device_interfaces = {device.name: {} for device in devices} 295 | for if_name, if_data in netbox_plain_interfaces.items(): 296 | interface = interface_lookup[if_name] 297 | gitlab_interfaces[master.name][if_name] = if_data 298 | gitlab_interfaces[interface.device.name][if_name] = if_data 299 | 300 | netbox_device_interfaces[master.name][if_name] = if_data 301 | netbox_device_interfaces[interface.device.name][if_name] = if_data 302 | 303 | # Construct the new contents of the whole file 304 | config = settings.PLUGINS_CONFIG['netbox_gitlab'] 305 | key = config['interfaces_key'] 306 | 307 | new_gitlab_data = {name: yaml.dump({key: netbox_interfaces}, 308 | Dumper=GitLabDumper, sort_keys=False, default_flow_style=False) 309 | for name, netbox_interfaces in gitlab_interfaces.items()} 310 | update = signing.dumps(new_gitlab_data, salt='netbox_gitlab.interfaces', compress=True) 311 | 312 | if not form: 313 | form = GitLabCommitInterfacesForm(initial={ 314 | 'pk': interface_ids, 315 | 'branch': request.POST['branch'], 316 | 'update': update, 317 | }) 318 | else: 319 | # Override the bound data 320 | form.data['update'] = update 321 | 322 | # Create the diffs for the selected interfaces 323 | diffs = make_diffs( 324 | devices=devices, 325 | gitlab_data=orig_gitlab_interfaces, 326 | netbox_data=netbox_device_interfaces 327 | ) 328 | 329 | return render(request, 'netbox_gitlab/export_interfaces.html', { 330 | 'branch': request.POST['branch'], 331 | 'device': base_device, 332 | 'diffs': diffs, 333 | 'form': form, 334 | 'return_url': self.get_return_url(request, base_device), 335 | }) 336 | 337 | def do_commit(self, request, base_device) -> HttpResponse: 338 | # Use a copy of the POST data so we can manipulate it later 339 | form = GitLabCommitInterfacesForm(copy.copy(request.POST)) 340 | if form.is_valid(): 341 | try: 342 | new_gitlab_data = signing.loads(request.POST['update'], salt='netbox_gitlab.interfaces', max_age=900) 343 | except SignatureExpired: 344 | messages.warning(request, "Update expired, please submit again") 345 | return self.show_diff(request, base_device, form) 346 | else: 347 | # Invalid form data, show form again 348 | messages.warning(request, "Form error, please submit again") 349 | return self.show_diff(request, base_device, form) 350 | 351 | # We appear to have new gitlab data! 352 | for device_name, content in new_gitlab_data.items(): 353 | device = get_object_or_404(Device, name=device_name) 354 | filename = self.config['interfaces_file'].format(device=device) 355 | self.gitlab_add_file(filename, content) 356 | 357 | branch = form.cleaned_data['branch'] 358 | changes, merge_req = self.commit(user=self.request.user, branch=branch) 359 | 360 | if not changes: 361 | messages.warning(self.request, f"Nothing has changed (changes already committed to branch {branch}?)") 362 | elif merge_req: 363 | messages.success(self.request, mark_safe( 364 | f'{changes} file(s) changed in branch {branch}, ' 365 | f'merge request {merge_req.iid} open' 366 | )) 367 | else: 368 | messages.success(self.request, f"{changes} file(s) changed in branch {branch}") 369 | 370 | return redirect(self.get_return_url(request, base_device)) 371 | 372 | def post(self, request, device_id: int) -> HttpResponse: 373 | device = get_object_or_404(Device, pk=device_id) 374 | 375 | # If we don't have a GitLab project we can't do anything 376 | if not self.project: 377 | messages.error(self.request, f"GitLab server error: {self.gitlab_error}") 378 | return redirect(self.get_return_url(request, device)) 379 | 380 | if request.POST.get('branch') and request.POST.get('update'): 381 | # If we have `branch` and `update` then the form was submitted 382 | return self.do_commit(request, device) 383 | elif request.POST.get('branch'): 384 | # If we have `branch` then we can show a diff 385 | return self.show_diff(request, device) 386 | else: 387 | # First ask which branch they want 388 | return self.ask_branch(request, device) 389 | --------------------------------------------------------------------------------