diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 172f634fb..3c0b30bcb 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -126,7 +126,7 @@ urlpatterns = [ url(r'^devices/(?P\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), url(r'^devices/(?P\d+)/config/$', views.DeviceConfigView.as_view(), name='device_config'), url(r'^devices/(?P\d+)/add-secret/$', secret_add, name='device_addsecret'), - url(r'^devices/(?P\d+)/services/assign/$', ServiceCreateView.as_view(), name='service_assign'), + url(r'^devices/(?P\d+)/services/assign/$', ServiceCreateView.as_view(), name='device_service_assign'), url(r'^devices/(?P\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}), # Console ports diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 3ef152ebe..a5b64fca6 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -12,6 +12,7 @@ from ipam.models import ( ) from tenancy.api.serializers import NestedTenantSerializer from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer +from virtualization.api.serializers import NestedVirtualMachineSerializer # @@ -295,12 +296,13 @@ class AvailableIPSerializer(serializers.Serializer): class ServiceSerializer(serializers.ModelSerializer): device = NestedDeviceSerializer() + virtual_machine = NestedVirtualMachineSerializer() protocol = ChoiceFieldSerializer(choices=IP_PROTOCOL_CHOICES) ipaddresses = NestedIPAddressSerializer(many=True) class Meta: model = Service - fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description'] + fields = ['id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description'] # TODO: Figure out how to use model validation with ManyToManyFields. Calling clean() yields a ValueError. @@ -308,4 +310,4 @@ class WritableServiceSerializer(serializers.ModelSerializer): class Meta: model = Service - fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description'] + fields = ['id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description'] diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 8eabdd9a9..a91c783d1 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -861,5 +861,14 @@ class ServiceForm(BootstrapMixin, forms.ModelForm): super(ServiceForm, self).__init__(*args, **kwargs) - # Limit IP address choices to those assigned to interfaces of the parent device - self.fields['ipaddresses'].queryset = IPAddress.objects.filter(interface__device=self.instance.device) + # Limit IP address choices to those assigned to interfaces of the parent device/VM + if self.instance.device: + self.fields['ipaddresses'].queryset = IPAddress.objects.filter( + interface__device=self.instance.device + ) + elif self.instance.virtual_machine: + self.fields['ipaddresses'].queryset = IPAddress.objects.filter( + interface__virtual_machine=self.instance.virtual_machine + ) + else: + self.fields['ipaddresses'].choices = [] diff --git a/netbox/ipam/migrations/0019_virtualization.py b/netbox/ipam/migrations/0019_virtualization.py new file mode 100644 index 000000000..955ff8b4a --- /dev/null +++ b/netbox/ipam/migrations/0019_virtualization.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-08-31 15:44 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0001_virtualization'), + ('ipam', '0018_remove_service_uniqueness_constraint'), + ] + + operations = [ + migrations.AlterModelOptions( + name='service', + options={'ordering': ['protocol', 'port']}, + ), + migrations.AddField( + model_name='service', + name='virtual_machine', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='services', to='virtualization.VirtualMachine'), + ), + migrations.AlterField( + model_name='service', + name='device', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='services', to='dcim.Device', verbose_name='device'), + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index ddc1e9e48..f34d55db7 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -588,20 +588,59 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): @python_2_unicode_compatible class Service(CreatedUpdatedModel): """ - A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device. A Service may optionally be tied - to one or more specific IPAddresses belonging to the Device. + A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may + optionally be tied to one or more specific IPAddresses belonging to its parent. """ - device = models.ForeignKey('dcim.Device', related_name='services', on_delete=models.CASCADE, verbose_name='device') - name = models.CharField(max_length=30) - protocol = models.PositiveSmallIntegerField(choices=IP_PROTOCOL_CHOICES) - port = models.PositiveIntegerField(validators=[MinValueValidator(1), MaxValueValidator(65535)], - verbose_name='Port number') - ipaddresses = models.ManyToManyField('ipam.IPAddress', related_name='services', blank=True, - verbose_name='IP addresses') - description = models.CharField(max_length=100, blank=True) + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='services', + verbose_name='device', + null=True, + blank=True + ) + virtual_machine = models.ForeignKey( + to='virtualization.VirtualMachine', + on_delete=models.CASCADE, + related_name='services', + null=True, + blank=True + ) + name = models.CharField( + max_length=30 + ) + protocol = models.PositiveSmallIntegerField( + choices=IP_PROTOCOL_CHOICES + ) + port = models.PositiveIntegerField( + validators=[MinValueValidator(1), MaxValueValidator(65535)], + verbose_name='Port number' + ) + ipaddresses = models.ManyToManyField( + to='ipam.IPAddress', + related_name='services', + blank=True, + verbose_name='IP addresses' + ) + description = models.CharField( + max_length=100, + blank=True + ) class Meta: - ordering = ['device', 'protocol', 'port'] + ordering = ['protocol', 'port'] def __str__(self): return '{} ({}/{})'.format(self.name, self.port, self.get_protocol_display()) + + @property + def parent(self): + return self.device or self.virtual_machine + + def clean(self): + + # A Service must belong to a Device *or* to a VirtualMachine + if self.device and self.virtual_machine: + raise ValidationError("A service cannot be associated with both a device and a virtual machine.") + if not self.device and not self.virtual_machine: + raise ValidationError("A service must be associated with either a device or a virtual machine.") diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index fcca34b0f..edaa31869 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -15,6 +15,7 @@ from utilities.paginator import EnhancedPaginator from utilities.views import ( BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, ) +from virtualization.models import VirtualMachine from . import filters, forms, tables from .constants import IPADDRESS_ROLE_ANYCAST from .models import ( @@ -838,10 +839,12 @@ class ServiceCreateView(PermissionRequiredMixin, ObjectEditView): def alter_obj(self, obj, request, url_args, url_kwargs): if 'device' in url_kwargs: obj.device = get_object_or_404(Device, pk=url_kwargs['device']) + elif 'virtualmachine' in url_kwargs: + obj.virtual_machine = get_object_or_404(VirtualMachine, pk=url_kwargs['virtualmachine']) return obj def get_return_url(self, request, obj): - return obj.device.get_absolute_url() + return obj.parent.get_absolute_url() class ServiceEditView(ServiceCreateView): diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 3bf92fbca..18e14b5e0 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -196,7 +196,7 @@ {% if services %} {% for service in services %} - {% include 'dcim/inc/service.html' %} + {% include 'ipam/inc/service.html' %} {% endfor %}
{% else %} @@ -206,7 +206,7 @@ {% endif %} {% if perms.ipam.add_service %} diff --git a/netbox/templates/dcim/inc/service.html b/netbox/templates/ipam/inc/service.html similarity index 74% rename from netbox/templates/dcim/inc/service.html rename to netbox/templates/ipam/inc/service.html index 1e42a1811..a88aff4ce 100644 --- a/netbox/templates/dcim/inc/service.html +++ b/netbox/templates/ipam/inc/service.html @@ -14,12 +14,12 @@ {% if perms.ipam.change_service %} - + {% endif %} {% if perms.ipam.delete_service %} - - + + {% endif %} diff --git a/netbox/templates/ipam/service_edit.html b/netbox/templates/ipam/service_edit.html index b5593de0d..e1db968dd 100644 --- a/netbox/templates/ipam/service_edit.html +++ b/netbox/templates/ipam/service_edit.html @@ -5,12 +5,21 @@
Service
-
- -
-

{{ obj.device }}

+ {% if obj.device %} +
+ +
+

{{ obj.device }}

+
-
+ {% else %} +
+ +
+

{{ obj.virtual_machine }}

+
+
+ {% endif %} {% render_field form.name %}
diff --git a/netbox/templates/virtualization/inc/interface.html b/netbox/templates/virtualization/inc/interface.html index 01307c9ca..ef37561bb 100644 --- a/netbox/templates/virtualization/inc/interface.html +++ b/netbox/templates/virtualization/inc/interface.html @@ -40,7 +40,7 @@ {% if ip.description %} {% endif %} - {% if device.primary_ip4 == ip or device.primary_ip6 == ip %} + {% if vm.primary_ip4 == ip or vm.primary_ip6 == ip %} Primary {% endif %} @@ -56,12 +56,12 @@ {% if perms.ipam.change_ipaddress %} - + {% endif %} {% if perms.ipam.delete_ipaddress %} - + {% endif %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index a88213090..7cef36ce3 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -158,6 +158,29 @@
+
+
+ Services +
+ {% if services %} + + {% for service in services %} + {% include 'ipam/inc/service.html' %} + {% endfor %} +
+ {% else %} +
+ None +
+ {% endif %} + {% if perms.ipam.add_service %} + + {% endif %} +
{% include 'inc/custom_fields_panel.html' with custom_fields=vm.get_custom_fields %}
diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index 222754dcb..c8d5163b6 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals from django.conf.urls import url +from ipam.views import ServiceCreateView from . import views @@ -39,6 +40,7 @@ urlpatterns = [ url(r'^virtual-machines/(?P\d+)/$', views.VirtualMachineView.as_view(), name='virtualmachine'), url(r'^virtual-machines/(?P\d+)/edit/$', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'), url(r'^virtual-machines/(?P\d+)/delete/$', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'), + url(r'^virtual-machines/(?P\d+)/services/assign/$', ServiceCreateView.as_view(), name='virtualmachine_service_assign'), # VM interfaces # url(r'^virtual-machines/interfaces/add/$', views.VMBulkAddInterfaceView.as_view(), name='vm_bulk_add_interface'), diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 599956e66..29a35f44b 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -9,6 +9,7 @@ from django.views.generic import View from dcim.models import Device, Interface from dcim.tables import DeviceTable +from ipam.models import Service from utilities.views import ( BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView, ComponentEditView, ObjectDeleteView, ObjectEditView, ObjectListView, @@ -236,10 +237,12 @@ class VirtualMachineView(View): vm = get_object_or_404(VirtualMachine.objects.select_related('tenant__group'), pk=pk) interfaces = Interface.objects.filter(virtual_machine=vm) + services = Service.objects.filter(virtual_machine=vm) return render(request, 'virtualization/virtualmachine.html', { 'vm': vm, 'interfaces': interfaces, + 'services': services, })