implemnted #2392 - local config context for devices and VMs

This commit is contained in:
John Anderson 2018-09-16 00:25:20 -04:00
parent e965adad7c
commit 0da113b723
20 changed files with 232 additions and 7 deletions

View File

@ -1,3 +1,5 @@
# Contextual Configuration Data # Contextual Configuration Data
Sometimes it is desirable to associate arbitrary data with a group of devices to aid in their configuration. For example, you might want to associate a set of syslog servers for all devices at a particular site. Context data enables the association of arbitrary data to devices and virtual machines grouped by region, site, role, platform, and/or tenant. Context data is arranged hierarchically, so that data with a higher weight can be entered to override more general lower-weight data. Multiple instances of data are automatically merged by NetBox to present a single dictionary for each object. Sometimes it is desirable to associate arbitrary data with a group of devices to aid in their configuration. For example, you might want to associate a set of syslog servers for all devices at a particular site. Context data enables the association of arbitrary data to devices and virtual machines grouped by region, site, role, platform, and/or tenant. Context data is arranged hierarchically, so that data with a higher weight can be entered to override more general lower-weight data. Multiple instances of data are automatically merged by NetBox to present a single dictionary for each object.
Devices and Virtual Machines may also have a local config context defined. This local context will always overwrite the rendered config context objects for the Device/VM. This is useful in situations were the device requires a one-off value different from the rest of the environment.

View File

@ -412,7 +412,7 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'custom_fields', 'created', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'custom_fields', 'created',
'last_updated', 'last_updated', 'local_config_context_data',
] ]
validators = [] validators = []
@ -448,7 +448,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'custom_fields', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'custom_fields',
'config_context', 'created', 'last_updated', 'config_context', 'created', 'last_updated', 'local_config_context_data',
] ]
def get_config_context(self, obj): def get_config_context(self, obj):

View File

@ -18,7 +18,7 @@ from utilities.forms import (
AnnotatedMultipleChoiceField, APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, AnnotatedMultipleChoiceField, APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm, BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm,
ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FilterTreeNodeMultipleChoiceField, ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FilterTreeNodeMultipleChoiceField,
FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField, FlexibleModelChoiceField, JSONField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField,
) )
from virtualization.models import Cluster from virtualization.models import Cluster
from .constants import ( from .constants import (
@ -920,6 +920,16 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
self.initial['rack'] = self.instance.parent_bay.device.rack_id self.initial['rack'] = self.instance.parent_bay.device.rack_id
class DeviceLocalConfigContextForm(BootstrapMixin, forms.ModelForm):
local_config_context_data = JSONField()
class Meta:
model = Device
fields = [
'local_config_context_data',
]
class BaseDeviceCSVForm(forms.ModelForm): class BaseDeviceCSVForm(forms.ModelForm):
device_role = forms.ModelChoiceField( device_role = forms.ModelChoiceField(
queryset=DeviceRole.objects.all(), queryset=DeviceRole.objects.all(),

View File

@ -0,0 +1,19 @@
# Generated by Django 2.0.8 on 2018-09-16 02:01
import django.contrib.postgres.fields.jsonb
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dcim', '0062_interface_mtu'),
]
operations = [
migrations.AddField(
model_name='device',
name='local_config_context_data',
field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True),
),
]

View File

@ -1287,6 +1287,10 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
images = GenericRelation( images = GenericRelation(
to='extras.ImageAttachment' to='extras.ImageAttachment'
) )
local_config_context_data = JSONField(
blank=True,
null=True,
)
objects = DeviceManager() objects = DeviceManager()
tags = TaggableManager() tags = TaggableManager()

View File

@ -142,6 +142,8 @@ urlpatterns = [
url(r'^devices/(?P<pk>\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'), url(r'^devices/(?P<pk>\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'),
url(r'^devices/(?P<pk>\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'), url(r'^devices/(?P<pk>\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'),
url(r'^devices/(?P<pk>\d+)/config-context/$', views.DeviceConfigContextView.as_view(), name='device_configcontext'), url(r'^devices/(?P<pk>\d+)/config-context/$', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
url(r'^devices/(?P<pk>\d+)/config-context/edit-local/$', views.DeviceEditLocalConfigContextView.as_view(), name='device_edit_localconfigcontext'),
url(r'^devices/(?P<pk>\d+)/config-context/clear-local/$', views.DeviceClearLocalContextDataView.as_view(), name='device_delete_localconfigcontext'),
url(r'^devices/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}), url(r'^devices/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
url(r'^devices/(?P<pk>\d+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'), url(r'^devices/(?P<pk>\d+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'),
url(r'^devices/(?P<pk>\d+)/status/$', views.DeviceStatusView.as_view(), name='device_status'), url(r'^devices/(?P<pk>\d+)/status/$', views.DeviceStatusView.as_view(), name='device_status'),

View File

@ -26,7 +26,7 @@ from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator from utilities.paginator import EnhancedPaginator
from utilities.views import ( from utilities.views import (
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, GetReturnURLMixin, BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, GetReturnURLMixin,
ObjectDeleteView, ObjectEditView, ObjectListView, ObjectDeleteView, ObjectEditView, ObjectListView, ObjectSetFieldNullView,
) )
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from . import filters, forms, tables from . import filters, forms, tables
@ -983,6 +983,19 @@ class DeviceEditView(DeviceCreateView):
permission_required = 'dcim.change_device' permission_required = 'dcim.change_device'
class DeviceEditLocalConfigContextView(DeviceCreateView):
permission_required = 'dcim.change_device'
model_form = forms.DeviceLocalConfigContextForm
template_name = 'dcim/device_edit_local_config_context.html'
class DeviceClearLocalContextDataView(ObjectSetFieldNullView):
permission_required = 'dcim.change_device'
model = Device
field = 'local_config_context_data'
field_human_friendly_name = 'local config context'
class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView): class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_device' permission_required = 'dcim.delete_device'
model = Device model = Device

View File

@ -716,6 +716,10 @@ class ConfigContextModel(models.Model):
for context in ConfigContext.objects.get_for_object(self): for context in ConfigContext.objects.get_for_object(self):
data.update(context.data) data.update(context.data)
# If the object has local config context data defined, that data overwrites all rendered data
if self.local_config_context_data is not None:
data.update(self.local_config_context_data)
return data return data

View File

@ -106,9 +106,15 @@ class ObjectConfigContextView(View):
obj = get_object_or_404(self.object_class, pk=pk) obj = get_object_or_404(self.object_class, pk=pk)
source_contexts = ConfigContext.objects.get_for_object(obj) source_contexts = ConfigContext.objects.get_for_object(obj)
model_name = self.object_class._meta.model_name
app_label = self.object_class._meta.app_label
return render(request, 'extras/object_configcontext.html', { return render(request, 'extras/object_configcontext.html', {
self.object_class._meta.model_name: obj, model_name: obj,
'obj': obj,
'perm_string': '{}.change_{}'.format(app_label, model_name),
'edit_url':'{}:{}_edit_localconfigcontext'.format(app_label, model_name),
'delete_url':'{}:{}_delete_localconfigcontext'.format(app_label, model_name),
'rendered_context': obj.get_config_context(), 'rendered_context': obj.get_config_context(),
'source_contexts': source_contexts, 'source_contexts': source_contexts,
'base_template': self.base_template, 'base_template': self.base_template,

View File

@ -0,0 +1,11 @@
{% extends 'utilities/obj_edit.html' %}
{% load form_helpers %}
{% block form %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Local Config Context Data</strong></div>
<div class="panel-body">
{% render_field form.local_config_context_data %}
</div>
</div>
{% endblock %}

View File

@ -16,6 +16,38 @@
</div> </div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Local Context</strong>
</div>
<div class="panel-body">
{% if obj.local_config_context_data %}
<pre>{{ obj.local_config_context_data|render_json }}</pre>
{% else %}
<span class="text-muted">None</span>
{% endif %}
<span class="help-block">
<i class="fa fa-info-circle"></i>
The local config context overwrites all source contexts.
</span>
</div>
<div class="panel-footer">
{% if perm_string in perms %}
{% if obj.local_config_context_data %}
<a href="{% url edit_url pk=obj.pk %}?return_url={{ obj.get_absolute_url }}config-context/" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Edit
</a>
<a href="{% url delete_url pk=obj.pk %}?return_url={{ obj.get_absolute_url }}config-context/" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</a>
{% else %}
<a href="{% url edit_url pk=obj.pk %}?return_url={{ obj.get_absolute_url }}config-context/" class="btn btn-success btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add local context
</a>
{% endif %}
{% endif %}
</div>
</div>
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>Source Contexts</strong> <strong>Source Contexts</strong>

View File

@ -0,0 +1,9 @@
{% extends 'utilities/confirmation_form.html' %}
{% load form_helpers %}
{% block title %}Clear {{ field_human_friendly_name }}?{% endblock %}
{% block message %}
<p>Are you sure you want to clear the <strong>{{ field_human_friendly_name }}</strong> on {{ obj_type }} <strong>{{ obj }}</strong>?</p>
{% block message_extra %}{% endblock %}
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends 'utilities/obj_edit.html' %}
{% load form_helpers %}
{% block form %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Local Config Context Data</strong></div>
<div class="panel-body">
{% render_field form.local_config_context_data %}
</div>
</div>
{% endblock %}

View File

@ -844,6 +844,56 @@ class BulkComponentCreateView(GetReturnURLMixin, View):
}) })
class ObjectSetFieldNullView(ObjectDeleteView):
"""
Given a field name, set it to None (null) and save the object.
field: The field to be nulled
field_friendly_name: Human friendly name for the field in the UI.
"""
template_name = 'utilities/object_set_field_null.html'
field_human_friendly_name = None
def get(self, request, **kwargs):
obj = self.get_object(kwargs)
form = ConfirmationForm(initial=request.GET)
return render(request, self.template_name, {
'obj': obj,
'form': form,
'obj_type': self.model._meta.verbose_name,
'field_human_friendly_name': self.field_human_friendly_name,
'return_url': self.get_return_url(request, obj),
})
def post(self, request, **kwargs):
obj = self.get_object(kwargs)
form = ConfirmationForm(request.POST)
if form.is_valid():
setattr(obj, self.field, None)
obj.save()
msg = 'Cleared {} on {} {}'.format(self.field_human_friendly_name, self.model._meta.verbose_name, obj)
messages.success(request, msg)
return_url = form.cleaned_data.get('return_url')
if return_url is not None and is_safe_url(url=return_url, host=request.get_host()):
return redirect(return_url)
else:
return redirect(self.get_return_url(request, obj))
return render(request, self.template_name, {
'obj': obj,
'form': form,
'obj_type': self.model._meta.verbose_name,
'field_human_friendly_name': self.field_human_friendly_name,
'return_url': self.get_return_url(request, obj),
})
@requires_csrf_token @requires_csrf_token
def server_error(request, template_name=ERROR_500_TEMPLATE_NAME): def server_error(request, template_name=ERROR_500_TEMPLATE_NAME):
""" """

View File

@ -107,6 +107,7 @@ class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer):
fields = [ fields = [
'id', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'id', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4',
'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'local_config_context_data',
] ]
@ -117,6 +118,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
fields = [ fields = [
'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6',
'vcpus', 'memory', 'disk', 'comments', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
'local_config_context_data',
] ]
def get_config_context(self, obj): def get_config_context(self, obj):

View File

@ -17,7 +17,8 @@ from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
AnnotatedMultipleChoiceField, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, AnnotatedMultipleChoiceField, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ComponentForm, ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ComponentForm,
ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, SlugField, SmallTextarea, add_blank_choice ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, JSONField, SlugField, SmallTextarea,
add_blank_choice
) )
from .constants import VM_STATUS_CHOICES from .constants import VM_STATUS_CHOICES
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@ -302,6 +303,16 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
self.fields['primary_ip6'].widget.attrs['readonly'] = True self.fields['primary_ip6'].widget.attrs['readonly'] = True
class VirtualMachineLocalConfigContextForm(BootstrapMixin, forms.ModelForm):
local_config_context_data = JSONField()
class Meta:
model = VirtualMachine
fields = [
'local_config_context_data',
]
class VirtualMachineCSVForm(forms.ModelForm): class VirtualMachineCSVForm(forms.ModelForm):
status = CSVChoiceField( status = CSVChoiceField(
choices=VM_STATUS_CHOICES, choices=VM_STATUS_CHOICES,

View File

@ -0,0 +1,19 @@
# Generated by Django 2.0.8 on 2018-09-16 02:01
import django.contrib.postgres.fields.jsonb
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('virtualization', '0007_change_logging'),
]
operations = [
migrations.AddField(
model_name='virtualmachine',
name='local_config_context_data',
field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True),
),
]

View File

@ -2,6 +2,7 @@ from __future__ import unicode_literals
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.postgres.fields import JSONField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
@ -244,6 +245,10 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
content_type_field='obj_type', content_type_field='obj_type',
object_id_field='obj_id' object_id_field='obj_id'
) )
local_config_context_data = JSONField(
blank=True,
null=True,
)
tags = TaggableManager() tags = TaggableManager()

View File

@ -49,6 +49,8 @@ urlpatterns = [
url(r'^virtual-machines/(?P<pk>\d+)/edit/$', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'), url(r'^virtual-machines/(?P<pk>\d+)/edit/$', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'),
url(r'^virtual-machines/(?P<pk>\d+)/delete/$', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'), url(r'^virtual-machines/(?P<pk>\d+)/delete/$', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'),
url(r'^virtual-machines/(?P<pk>\d+)/config-context/$', views.VirtualMachineConfigContextView.as_view(), name='virtualmachine_configcontext'), url(r'^virtual-machines/(?P<pk>\d+)/config-context/$', views.VirtualMachineConfigContextView.as_view(), name='virtualmachine_configcontext'),
url(r'^virtual-machines/(?P<pk>\d+)/config-context/edit-local/$', views.VirtualMachineEditLocalConfigContextView.as_view(), name='virtualmachine_edit_localconfigcontext'),
url(r'^virtual-machines/(?P<pk>\d+)/config-context/clear-local/$', views.VirtualMachineClearLocalContextDataView.as_view(), name='virtualmachine_delete_localconfigcontext'),
url(r'^virtual-machines/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}), url(r'^virtual-machines/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}),
url(r'^virtual-machines/(?P<virtualmachine>\d+)/services/assign/$', ServiceCreateView.as_view(), name='virtualmachine_service_assign'), url(r'^virtual-machines/(?P<virtualmachine>\d+)/services/assign/$', ServiceCreateView.as_view(), name='virtualmachine_service_assign'),

View File

@ -14,7 +14,7 @@ from extras.views import ObjectConfigContextView
from ipam.models import Service from ipam.models import Service
from utilities.views import ( from utilities.views import (
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView, BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView,
ObjectEditView, ObjectListView, ObjectEditView, ObjectListView, ObjectSetFieldNullView,
) )
from . import filters, forms, tables from . import filters, forms, tables
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@ -285,6 +285,19 @@ class VirtualMachineCreateView(PermissionRequiredMixin, ObjectEditView):
default_return_url = 'virtualization:virtualmachine_list' default_return_url = 'virtualization:virtualmachine_list'
class VirtualMachineEditLocalConfigContextView(VirtualMachineCreateView):
permission_required = 'virtualization.change_device'
model_form = forms.VirtualMachineLocalConfigContextForm
template_name = 'virtualization/virtualmachine_edit_local_config_context.html'
class VirtualMachineClearLocalContextDataView(ObjectSetFieldNullView):
permission_required = 'virtualization.change_virtualmachine'
model = VirtualMachine
field = 'local_config_context_data'
field_human_friendly_name = 'local config context'
class VirtualMachineEditView(VirtualMachineCreateView): class VirtualMachineEditView(VirtualMachineCreateView):
permission_required = 'virtualization.change_virtualmachine' permission_required = 'virtualization.change_virtualmachine'