Initial work on #2179: Allow a service to have multiple ports

This commit is contained in:
Jeremy Stretch 2020-09-21 13:21:41 -04:00
parent 0cc2a6b2cf
commit f97d8963f2
13 changed files with 118 additions and 51 deletions

View File

@ -117,4 +117,4 @@ class NestedServiceSerializer(WritableNestedSerializer):
class Meta: class Meta:
model = models.Service model = models.Service
fields = ['id', 'url', 'name', 'protocol', 'port'] fields = ['id', 'url', 'name', 'protocol', 'ports']

View File

@ -282,6 +282,6 @@ class ServiceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
class Meta: class Meta:
model = Service model = Service
fields = [ fields = [
'id', 'url', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'tags', 'id', 'url', 'device', 'virtual_machine', 'name', 'ports', 'protocol', 'ipaddresses', 'description', 'tags',
'custom_fields', 'created', 'last_updated', 'custom_fields', 'created', 'last_updated',
] ]

View File

@ -546,7 +546,7 @@ class ServiceFilterSet(BaseFilterSet, CreatedUpdatedFilterSet):
class Meta: class Meta:
model = Service model = Service
fields = ['id', 'name', 'protocol', 'port'] fields = ['id', 'name', 'protocol']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():

View File

@ -10,8 +10,8 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, CSVChoiceField, CSVModelChoiceField, CSVModelForm, add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, CSVChoiceField, CSVModelChoiceField, CSVModelForm,
DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField, ReturnURLForm, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField, NumericArrayField,
SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
) )
from virtualization.models import Cluster, VirtualMachine, VMInterface from virtualization.models import Cluster, VirtualMachine, VMInterface
from .choices import * from .choices import *
@ -1155,9 +1155,12 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
# #
class ServiceForm(BootstrapMixin, CustomFieldModelForm): class ServiceForm(BootstrapMixin, CustomFieldModelForm):
port = forms.IntegerField( ports = NumericArrayField(
min_value=SERVICE_PORT_MIN, base_field=forms.IntegerField(
max_value=SERVICE_PORT_MAX min_value=SERVICE_PORT_MIN,
max_value=SERVICE_PORT_MAX
),
help_text="Comma-separated list of numeric unit IDs. A range may be specified using a hyphen."
) )
tags = DynamicModelMultipleChoiceField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(), queryset=Tag.objects.all(),
@ -1167,7 +1170,7 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm):
class Meta: class Meta:
model = Service model = Service
fields = [ fields = [
'name', 'protocol', 'port', 'ipaddresses', 'description', 'tags', 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'tags',
] ]
help_texts = { help_texts = {
'ipaddresses': "IP address assignment is optional. If no IPs are selected, the service is assumed to be " 'ipaddresses': "IP address assignment is optional. If no IPs are selected, the service is assumed to be "
@ -1244,11 +1247,11 @@ class ServiceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
required=False, required=False,
widget=StaticSelect2() widget=StaticSelect2()
) )
port = forms.IntegerField( ports = NumericArrayField(
validators=[ base_field=forms.IntegerField(
MinValueValidator(1), min_value=SERVICE_PORT_MIN,
MaxValueValidator(65535), max_value=SERVICE_PORT_MAX
], ),
required=False required=False
) )
description = forms.CharField( description = forms.CharField(

View File

@ -0,0 +1,43 @@
import django.contrib.postgres.fields
import django.core.validators
from django.db import migrations, models
def replicate_ports(apps, schema_editor):
Service = apps.get_model('ipam', 'Service')
# TODO: Figure out how to cast IntegerField to an array so we can use .update()
for service in Service.objects.all():
Service.objects.filter(pk=service.pk).update(ports=[service.port])
class Migration(migrations.Migration):
dependencies = [
('ipam', '0038_custom_field_data'),
]
operations = [
migrations.AddField(
model_name='service',
name='ports',
field=django.contrib.postgres.fields.ArrayField(
base_field=models.PositiveIntegerField(
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(65535)
]
),
default=[],
size=None
),
preserve_default=False,
),
migrations.AlterModelOptions(
name='service',
options={'ordering': ('protocol', 'ports', 'pk')},
),
migrations.RunPython(
code=replicate_ports
),
]

View File

@ -0,0 +1,15 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('ipam', '0039_service_ports_array'),
]
operations = [
migrations.RemoveField(
model_name='service',
name='port',
),
]

View File

@ -2,6 +2,7 @@ import netaddr
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
@ -1008,12 +1009,14 @@ class Service(ChangeLoggedModel, CustomFieldModel):
max_length=50, max_length=50,
choices=ServiceProtocolChoices choices=ServiceProtocolChoices
) )
port = models.PositiveIntegerField( ports = ArrayField(
validators=[ base_field=models.PositiveIntegerField(
MinValueValidator(SERVICE_PORT_MIN), validators=[
MaxValueValidator(SERVICE_PORT_MAX) MinValueValidator(SERVICE_PORT_MIN),
], MaxValueValidator(SERVICE_PORT_MAX)
verbose_name='Port number' ]
),
verbose_name='Port numbers'
) )
ipaddresses = models.ManyToManyField( ipaddresses = models.ManyToManyField(
to='ipam.IPAddress', to='ipam.IPAddress',
@ -1029,13 +1032,13 @@ class Service(ChangeLoggedModel, CustomFieldModel):
objects = RestrictedQuerySet.as_manager() objects = RestrictedQuerySet.as_manager()
csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'port', 'description'] csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'ports', 'description']
class Meta: class Meta:
ordering = ('protocol', 'port', 'pk') # (protocol, port) may be non-unique ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique
def __str__(self): def __str__(self):
return '{} ({}/{})'.format(self.name, self.port, self.get_protocol_display()) return f'{self.name} ({self.get_protocol_display()}/{self.ports})'
def get_absolute_url(self): def get_absolute_url(self):
return reverse('ipam:service', args=[self.pk]) return reverse('ipam:service', args=[self.pk])
@ -1058,6 +1061,6 @@ class Service(ChangeLoggedModel, CustomFieldModel):
self.virtual_machine.name if self.virtual_machine else None, self.virtual_machine.name if self.virtual_machine else None,
self.name, self.name,
self.get_protocol_display(), self.get_protocol_display(),
self.port, self.ports,
self.description, self.description,
) )

View File

@ -623,11 +623,14 @@ class ServiceTable(BaseTable):
parent = tables.LinkColumn( parent = tables.LinkColumn(
order_by=('device', 'virtual_machine') order_by=('device', 'virtual_machine')
) )
ports = tables.Column(
orderable=False
)
tags = TagColumn( tags = TagColumn(
url_name='ipam:service_list' url_name='ipam:service_list'
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Service model = Service
fields = ('pk', 'name', 'parent', 'protocol', 'port', 'ipaddresses', 'description', 'tags') fields = ('pk', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags')
default_columns = ('pk', 'name', 'parent', 'protocol', 'port', 'description') default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description')

View File

@ -428,7 +428,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase):
class ServiceTest(APIViewTestCases.APIViewTestCase): class ServiceTest(APIViewTestCases.APIViewTestCase):
model = Service model = Service
brief_fields = ['id', 'name', 'port', 'protocol', 'url'] brief_fields = ['id', 'name', 'ports', 'protocol', 'url']
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -444,9 +444,9 @@ class ServiceTest(APIViewTestCases.APIViewTestCase):
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
services = ( services = (
Service(device=devices[0], name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=1), Service(device=devices[0], name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1]),
Service(device=devices[0], name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=2), Service(device=devices[0], name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2]),
Service(device=devices[0], name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=3), Service(device=devices[0], name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[3]),
) )
Service.objects.bulk_create(services) Service.objects.bulk_create(services)
@ -455,18 +455,18 @@ class ServiceTest(APIViewTestCases.APIViewTestCase):
'device': devices[1].pk, 'device': devices[1].pk,
'name': 'Service 4', 'name': 'Service 4',
'protocol': ServiceProtocolChoices.PROTOCOL_TCP, 'protocol': ServiceProtocolChoices.PROTOCOL_TCP,
'port': 4, 'ports': [4],
}, },
{ {
'device': devices[1].pk, 'device': devices[1].pk,
'name': 'Service 5', 'name': 'Service 5',
'protocol': ServiceProtocolChoices.PROTOCOL_TCP, 'protocol': ServiceProtocolChoices.PROTOCOL_TCP,
'port': 5, 'ports': [5],
}, },
{ {
'device': devices[1].pk, 'device': devices[1].pk,
'name': 'Service 6', 'name': 'Service 6',
'protocol': ServiceProtocolChoices.PROTOCOL_TCP, 'protocol': ServiceProtocolChoices.PROTOCOL_TCP,
'port': 6, 'ports': [6],
}, },
] ]

View File

@ -742,12 +742,12 @@ class ServiceTestCase(TestCase):
VirtualMachine.objects.bulk_create(virtual_machines) VirtualMachine.objects.bulk_create(virtual_machines)
services = ( services = (
Service(device=devices[0], name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=1001), Service(device=devices[0], name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1001]),
Service(device=devices[1], name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=1002), Service(device=devices[1], name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1002]),
Service(device=devices[2], name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_UDP, port=1003), Service(device=devices[2], name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[1003]),
Service(virtual_machine=virtual_machines[0], name='Service 4', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=2001), Service(virtual_machine=virtual_machines[0], name='Service 4', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2001]),
Service(virtual_machine=virtual_machines[1], name='Service 5', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=2002), Service(virtual_machine=virtual_machines[1], name='Service 5', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2002]),
Service(virtual_machine=virtual_machines[2], name='Service 6', protocol=ServiceProtocolChoices.PROTOCOL_UDP, port=2003), Service(virtual_machine=virtual_machines[2], name='Service 6', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[2003]),
) )
Service.objects.bulk_create(services) Service.objects.bulk_create(services)
@ -763,9 +763,9 @@ class ServiceTestCase(TestCase):
params = {'protocol': ServiceProtocolChoices.PROTOCOL_TCP} params = {'protocol': ServiceProtocolChoices.PROTOCOL_TCP}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_port(self): # def test_port(self):
params = {'port': ['1001', '1002', '1003']} # params = {'port': ['1001', '1002', '1003']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) # self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_device(self): def test_device(self):
devices = Device.objects.all()[:2] devices = Device.objects.all()[:2]

View File

@ -373,9 +373,9 @@ class ServiceTestCase(
device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, device_role=devicerole) device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, device_role=devicerole)
Service.objects.bulk_create([ Service.objects.bulk_create([
Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=101), Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]),
Service(device=device, name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=102), Service(device=device, name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[102]),
Service(device=device, name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=103), Service(device=device, name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[103]),
]) ])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
@ -385,14 +385,14 @@ class ServiceTestCase(
'virtual_machine': None, 'virtual_machine': None,
'name': 'Service X', 'name': 'Service X',
'protocol': ServiceProtocolChoices.PROTOCOL_TCP, 'protocol': ServiceProtocolChoices.PROTOCOL_TCP,
'port': 999, 'ports': '104,105',
'ipaddresses': [], 'ipaddresses': [],
'description': 'A new service', 'description': 'A new service',
'tags': [t.pk for t in tags], 'tags': [t.pk for t in tags],
} }
cls.csv_data = ( cls.csv_data = (
"device,name,protocol,port,description", "device,name,protocol,ports,description",
"Device 1,Service 1,tcp,1,First service", "Device 1,Service 1,tcp,1,First service",
"Device 1,Service 2,tcp,2,Second service", "Device 1,Service 2,tcp,2,Second service",
"Device 1,Service 3,udp,3,Third service", "Device 1,Service 3,udp,3,Third service",
@ -400,6 +400,6 @@ class ServiceTestCase(
cls.bulk_edit_data = { cls.bulk_edit_data = {
'protocol': ServiceProtocolChoices.PROTOCOL_UDP, 'protocol': ServiceProtocolChoices.PROTOCOL_UDP,
'port': 888, 'ports': '106,107',
'description': 'New description', 'description': 'New description',
} }

View File

@ -62,8 +62,8 @@
<td>{{ service.get_protocol_display }}</td> <td>{{ service.get_protocol_display }}</td>
</tr> </tr>
<tr> <tr>
<td>Port</td> <td>Ports</td>
<td>{{ service.port }}</td> <td>{{ service.ports|join:", " }}</td>
</tr> </tr>
<tr> <tr>
<td>IP Addresses</td> <td>IP Addresses</td>

View File

@ -25,7 +25,7 @@
<label class="col-md-3 control-label required">Port</label> <label class="col-md-3 control-label required">Port</label>
<div class="col-md-9"> <div class="col-md-9">
{{ form.protocol }} {{ form.protocol }}
{{ form.port }} {{ form.ports }}
</div> </div>
</div> </div>
{% render_field form.ipaddresses %} {% render_field form.ipaddresses %}