Fixes #22273: Fix migration failure when a service has thousands of ports defined

This commit is contained in:
mburggraf 2026-06-10 18:27:50 +02:00 committed by GitHub
parent 34f2ca6f84
commit b4116f2532
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 150 additions and 6 deletions

View File

@ -244,7 +244,7 @@ class RouteTargetType(PrimaryObjectType):
@strawberry_django.type(
models.Service,
exclude=('parent_object_type', 'parent_object_id'),
exclude=('_ports_lowest', 'parent_object_type', 'parent_object_id'),
filters=ServiceFilter,
pagination=True
)
@ -264,7 +264,7 @@ class ServiceType(ContactsMixin, PrimaryObjectType):
@strawberry_django.type(
models.ServiceTemplate,
fields='__all__',
exclude=('_ports_lowest',),
filters=ServiceTemplateFilter,
pagination=True
)

View File

@ -32,9 +32,12 @@ class Migration(migrations.Migration):
model_name='role',
index=models.Index(fields=['weight', 'name'], name='ipam_role_weight_01396b_idx'),
),
# Adding a dummy index, to allow a safe migration in case updating users already have services
# with a large number of ports configured (see issue #22273)
# Will get removed in 0091_alter_service_index_and_ordering
migrations.AddIndex(
model_name='service',
index=models.Index(fields=['protocol', 'ports', 'id'], name='ipam_servic_protoco_687d13_idx'),
index=models.Index(fields=['id'], name='ipam_servic_protoco_687d13_idx'),
),
migrations.AddIndex(
model_name='vlangroup',

View File

@ -0,0 +1,58 @@
from django.db import migrations, models
def populate__ports_lowest(apps, schema_editor):
Service = apps.get_model('ipam', 'Service')
ServiceTemplate = apps.get_model('ipam', 'ServiceTemplate')
CHUNK_SIZE = 500
for model in (Service, ServiceTemplate):
chunk = []
qs = model.objects.filter(_ports_lowest__isnull=True).only('id', 'ports', '_ports_lowest')
for obj in qs.iterator(chunk_size=CHUNK_SIZE):
if obj.ports:
obj._ports_lowest = min(obj.ports)
chunk.append(obj)
if len(chunk) >= CHUNK_SIZE:
model.objects.bulk_update(chunk, ['_ports_lowest'])
chunk = []
if chunk:
model.objects.bulk_update(chunk, ['_ports_lowest'])
class Migration(migrations.Migration):
dependencies = [
('ipam', '0090_vlangroup_recompute_total_vlan_ids'),
]
operations = [
migrations.RemoveIndex(
model_name='service',
name='ipam_servic_protoco_687d13_idx',
),
migrations.AddField(
model_name='service',
name='_ports_lowest',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='servicetemplate',
name='_ports_lowest',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.RunPython(populate__ports_lowest, migrations.RunPython.noop),
migrations.AddIndex(
model_name='service',
index=models.Index(
fields=['protocol', '_ports_lowest', 'id'],
name='ipam_servic_protoco_e2901d_idx'
),
),
migrations.AlterModelOptions(
name='service',
options={
'ordering': ('protocol', '_ports_lowest', 'id')
},
),
]

View File

@ -31,10 +31,22 @@ class ServiceBase(models.Model):
),
verbose_name=_('port numbers')
)
_ports_lowest = models.PositiveIntegerField(
null=True,
blank=True,
)
class Meta:
abstract = True
def save(self, *args, **kwargs):
# On saving find the smallest port and save for default ordering
self._ports_lowest = min(self.ports) if self.ports else None
update_fields = kwargs.get('update_fields')
if update_fields is not None and '_ports_lowest' not in update_fields:
kwargs['update_fields'] = list(update_fields) + ['_ports_lowest']
super().save(*args, **kwargs)
def __str__(self):
return f'{self.name} ({self.get_protocol_display()}/{self.port_list})'
@ -74,7 +86,6 @@ class Service(ContactsMixin, ServiceBase, PrimaryModel):
ct_field='parent_object_type',
fk_field='parent_object_id'
)
name = models.CharField(
max_length=100,
verbose_name=_('name')
@ -93,9 +104,9 @@ class Service(ContactsMixin, ServiceBase, PrimaryModel):
class Meta:
indexes = (
models.Index(fields=('protocol', 'ports', 'id')), # Default ordering
models.Index(fields=('protocol', '_ports_lowest', 'id')), # Default ordering
models.Index(fields=('parent_object_type', 'parent_object_id')),
)
ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique
ordering = ('protocol', '_ports_lowest', 'id')
verbose_name = _('application service')
verbose_name_plural = _('application services')

View File

@ -6,8 +6,10 @@ from netaddr import IPNetwork, IPSet
from dcim.models import Site, SiteGroup
from ipam.choices import *
from ipam.constants import SERVICE_PORT_MAX, SERVICE_PORT_MIN
from ipam.models import *
from utilities.data import string_to_ranges
from virtualization.models import VirtualMachine
class AggregateTestCase(TestCase):
@ -926,3 +928,73 @@ class VLANTestCase(TestCase):
vlan.group = vlangroups[2]
with self.assertRaises(ValidationError):
vlan.full_clean()
class ServiceTemplateTestCase(TestCase):
def test_servicetemplate_lowest_port(self):
"""
Test lowest port setting for servicetemplate
"""
template = ServiceTemplate(
name='Template 1',
protocol=ServiceProtocolChoices.PROTOCOL_TCP,
ports=[80, 443, 22, 8080], # small test list
)
template.full_clean()
template.save()
self.assertEqual(template._ports_lowest, 22)
def test_servicetemplate_single_port(self):
"""
Test with a single port
"""
template = ServiceTemplate(
name='Template 2',
protocol=ServiceProtocolChoices.PROTOCOL_UDP,
ports=[53],
)
template.full_clean()
template.save()
self.assertEqual(template._ports_lowest, 53)
def test_servicetemplate_empty_ports(self):
"""
Test with empty ports list
"""
template = ServiceTemplate(
name='Template 3',
protocol=ServiceProtocolChoices.PROTOCOL_TCP,
ports=[],
)
self.assertRaises(ValidationError, template.full_clean)
class ServiceTestCase(TestCase):
@classmethod
def setUpTestData(cls):
site = Site.objects.create(
name='Site 1',
slug='site-1',
)
VirtualMachine.objects.create(
name='virtual machine 1',
site=site,
)
def test_large_service(self):
"""
Test creation of service with large number of ports.
Related to issue #22273
"""
service = Service(
name='Service 1',
protocol=ServiceProtocolChoices.PROTOCOL_TCP,
ports=list(range(SERVICE_PORT_MIN, SERVICE_PORT_MAX)),
parent=VirtualMachine.objects.first(),
)
service.full_clean()
# Testing .save() is the important part, to check for database problems
service.save()
self.assertEqual(service._ports_lowest, SERVICE_PORT_MIN)