diff --git a/netbox/ipam/migrations/0087_ipaddress_iprange_prefix_parent.py b/netbox/ipam/migrations/0087_ipaddress_iprange_prefix_parent.py index 556ebe897..e601167af 100644 --- a/netbox/ipam/migrations/0087_ipaddress_iprange_prefix_parent.py +++ b/netbox/ipam/migrations/0087_ipaddress_iprange_prefix_parent.py @@ -17,7 +17,7 @@ class Migration(migrations.Migration): field=models.ForeignKey( blank=True, null=True, - on_delete=django.db.models.deletion.SET_NULL, + on_delete=django.db.models.deletion.DO_NOTHING, related_name='children', to='ipam.prefix', ), diff --git a/netbox/ipam/migrations/0089_alter_prefix_parent.py b/netbox/ipam/migrations/0089_alter_prefix_parent.py deleted file mode 100644 index a87765b9c..000000000 --- a/netbox/ipam/migrations/0089_alter_prefix_parent.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 5.2.5 on 2025-11-25 03:53 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('ipam', '0089_prefix_ipam_prefix_delete_prefix_ipam_prefix_insert'), - ] - - operations = [ - migrations.AlterField( - model_name='prefix', - name='parent', - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.DO_NOTHING, - related_name='children', - to='ipam.prefix', - ), - ), - ] diff --git a/netbox/ipam/migrations/0089_prefix_ipam_prefix_delete_prefix_ipam_prefix_insert.py b/netbox/ipam/migrations/0089_prefix_ipam_prefix_delete_prefix_ipam_prefix_insert.py deleted file mode 100644 index c3b172aaa..000000000 --- a/netbox/ipam/migrations/0089_prefix_ipam_prefix_delete_prefix_ipam_prefix_insert.py +++ /dev/null @@ -1,43 +0,0 @@ -# Generated by Django 5.2.5 on 2025-11-06 03:24 - -import pgtrigger.compiler -import pgtrigger.migrations -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('ipam', '0088_ipaddress_iprange_prefix_parent_data'), - ] - - operations = [ - pgtrigger.migrations.AddTrigger( - model_name='prefix', - trigger=pgtrigger.compiler.Trigger( - name='ipam_prefix_delete', - sql=pgtrigger.compiler.UpsertTriggerSql( - func="\n-- Update Child Prefix's with Prefix's PARENT\nUPDATE ipam_prefix SET parent_id=OLD.parent_id WHERE parent_id=OLD.id;\nRETURN OLD;\n", # noqa: E501 - hash='899e1943cb201118be7ef02f36f49747224774f2', - operation='DELETE', - pgid='pgtrigger_ipam_prefix_delete_e7810', - table='ipam_prefix', - when='BEFORE', - ), - ), - ), - pgtrigger.migrations.AddTrigger( - model_name='prefix', - trigger=pgtrigger.compiler.Trigger( - name='ipam_prefix_insert', - sql=pgtrigger.compiler.UpsertTriggerSql( - func="\nUPDATE ipam_prefix\nSET parent_id=NEW.id \nWHERE \n prefix << NEW.prefix\n AND\n (\n (vrf_id = NEW.vrf_id OR (vrf_id IS NULL AND NEW.vrf_id IS NULL))\n OR\n (\n NEW.vrf_id IS NULL\n AND\n NEW.status = 'container'\n AND\n NOT EXISTS(\n SELECT 1 FROM ipam_prefix p WHERE p.prefix >> ipam_prefix.prefix AND p.vrf_id = ipam_prefix.vrf_id\n )\n )\n )\n AND id != NEW.id\n AND NOT EXISTS (\n SELECT 1 FROM ipam_prefix p\n WHERE\n p.prefix >> ipam_prefix.prefix\n AND p.prefix << NEW.prefix\n AND (\n (p.vrf_id = ipam_prefix.vrf_id OR (p.vrf_id IS NULL AND ipam_prefix.vrf_id IS NULL))\n OR\n (p.vrf_id IS NULL AND p.status = 'container')\n )\n AND p.id != NEW.id\n )\n;\nRETURN NEW;\n", # noqa: E501 - hash='0e05bbe61861227a9eb710b6c94bae9e0cc7119e', - operation='INSERT', - pgid='pgtrigger_ipam_prefix_insert_46c72', - table='ipam_prefix', - when='AFTER', - ), - ), - ), - ] diff --git a/netbox/ipam/migrations/0090_update_trigger.py b/netbox/ipam/migrations/0090_update_trigger.py deleted file mode 100644 index 4d32b8f5d..000000000 --- a/netbox/ipam/migrations/0090_update_trigger.py +++ /dev/null @@ -1,65 +0,0 @@ -# Generated by Django 5.2.5 on 2025-11-25 06:00 - -import pgtrigger.compiler -import pgtrigger.migrations -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('ipam', '0089_alter_prefix_parent'), - ] - - operations = [ - pgtrigger.migrations.RemoveTrigger( - model_name='prefix', - name='ipam_prefix_delete', - ), - pgtrigger.migrations.RemoveTrigger( - model_name='prefix', - name='ipam_prefix_insert', - ), - pgtrigger.migrations.AddTrigger( - model_name='prefix', - trigger=pgtrigger.compiler.Trigger( - name='ipam_prefix_delete', - sql=pgtrigger.compiler.UpsertTriggerSql( - func="\n-- Update Child Prefix's with Prefix's PARENT This is a safe assumption based on the fact that the parent would be the\n-- next direct parent for anything else that could contain this prefix\nUPDATE ipam_prefix SET parent_id=OLD.parent_id WHERE parent_id=OLD.id;\nRETURN OLD;\n", # noqa: E501 - hash='ee3f890009c05a3617428158e7b6f3d77317885d', - operation='DELETE', - pgid='pgtrigger_ipam_prefix_delete_e7810', - table='ipam_prefix', - when='BEFORE', - ), - ), - ), - pgtrigger.migrations.AddTrigger( - model_name='prefix', - trigger=pgtrigger.compiler.Trigger( - name='ipam_prefix_insert', - sql=pgtrigger.compiler.UpsertTriggerSql( - func="\n-- Update the prefix with the new parent if the parent is the most appropriate prefix\nUPDATE ipam_prefix\nSET parent_id=NEW.id\nWHERE\n prefix << NEW.prefix\n AND\n (\n (vrf_id = NEW.vrf_id OR (vrf_id IS NULL AND NEW.vrf_id IS NULL))\n OR\n (\n NEW.vrf_id IS NULL\n AND\n NEW.status = 'container'\n AND\n NOT EXISTS(\n SELECT 1 FROM ipam_prefix p WHERE p.prefix >> ipam_prefix.prefix AND p.vrf_id = ipam_prefix.vrf_id\n )\n )\n )\n AND id != NEW.id\n AND NOT EXISTS (\n SELECT 1 FROM ipam_prefix p\n WHERE\n p.prefix >> ipam_prefix.prefix\n AND p.prefix << NEW.prefix\n AND (\n (p.vrf_id = ipam_prefix.vrf_id OR (p.vrf_id IS NULL AND ipam_prefix.vrf_id IS NULL))\n OR\n (p.vrf_id IS NULL AND p.status = 'container')\n )\n AND p.id != NEW.id\n )\n;\nRETURN NEW;\n", # noqa: E501 - hash='1d71498f09e767183d3b0d29c06c9ac9e2cc000a', - operation='INSERT', - pgid='pgtrigger_ipam_prefix_insert_46c72', - table='ipam_prefix', - when='AFTER', - ), - ), - ), - pgtrigger.migrations.AddTrigger( - model_name='prefix', - trigger=pgtrigger.compiler.Trigger( - name='ipam_prefix_update', - sql=pgtrigger.compiler.UpsertTriggerSql( - func="\n-- When a prefix changes, reassign any IPAddresses that no longer\n-- fall within the new prefix range to the parent prefix (or set null if no parent exists)\nUPDATE ipam_prefix\nSET parent_id = OLD.parent_id\nWHERE\n parent_id = NEW.id\n -- IP address no longer contained within the updated prefix\n AND NOT (prefix << NEW.prefix);\n\n-- Update the prefix with the new parent if the parent is the most appropriate prefix\nUPDATE ipam_prefix\nSET parent_id=NEW.id\nWHERE\n prefix << NEW.prefix\n AND\n (\n (vrf_id = NEW.vrf_id OR (vrf_id IS NULL AND NEW.vrf_id IS NULL))\n OR\n (\n NEW.vrf_id IS NULL\n AND\n NEW.status = 'container'\n AND\n NOT EXISTS(\n SELECT 1 FROM ipam_prefix p WHERE p.prefix >> ipam_prefix.prefix AND p.vrf_id = ipam_prefix.vrf_id\n )\n )\n )\n AND id != NEW.id\n AND NOT EXISTS (\n SELECT 1 FROM ipam_prefix p\n WHERE\n p.prefix >> ipam_prefix.prefix\n AND p.prefix << NEW.prefix\n AND (\n (p.vrf_id = ipam_prefix.vrf_id OR (p.vrf_id IS NULL AND ipam_prefix.vrf_id IS NULL))\n OR\n (p.vrf_id IS NULL AND p.status = 'container')\n )\n AND p.id != NEW.id\n )\n;\nRETURN NEW;\n", # noqa: E501 - hash='747230a84703df5a4aa3d32e7f45b5a32525b799', - operation='UPDATE', - pgid='pgtrigger_ipam_prefix_update_e5fca', - table='ipam_prefix', - when='AFTER', - ), - ), - ), - ] diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index 09d30e487..7a0680757 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -262,15 +262,6 @@ class TestPrefix(TestCase): # Global container should return all children self.assertSetEqual(child_ip_pks, {ips[0].pk, ips[1].pk, ips[2].pk, ips[3].pk}) - parent_prefix.vrf = vrfs[0] - parent_prefix.save() - - parent_prefix.refresh_from_db() - child_ip_pks = {p.pk for p in parent_prefix.ip_addresses.all()} - - # VRF container is limited to its own VRF - self.assertSetEqual(child_ip_pks, {ips[1].pk}) - def test_get_available_prefixes(self): prefixes = Prefix.objects.bulk_create(( @@ -417,6 +408,63 @@ class TestPrefix(TestCase): duplicate_prefix = Prefix(vrf=vrf, prefix=IPNetwork('192.0.2.0/24')) self.assertRaises(ValidationError, duplicate_prefix.clean) + def test_parent_container_prefix_change(self): + vrfs = VRF.objects.bulk_create(( + VRF(name='VRF 1'), + VRF(name='VRF 2'), + VRF(name='VRF 3'), + )) + parent_prefix = Prefix.objects.create( + prefix=IPNetwork('10.0.0.0/16'), status=PrefixStatusChoices.STATUS_CONTAINER + ) + ips = IPAddress.objects.bulk_create(( + IPAddress(prefix=parent_prefix, address=IPNetwork('10.0.0.1/24'), vrf=None), + IPAddress(prefix=parent_prefix, address=IPNetwork('10.0.1.1/24'), vrf=vrfs[0]), + IPAddress(prefix=parent_prefix, address=IPNetwork('10.0.2.1/24'), vrf=vrfs[1]), + IPAddress(prefix=parent_prefix, address=IPNetwork('10.0.3.1/24'), vrf=vrfs[2]), + )) + child_ip_pks = {p.pk for p in parent_prefix.ip_addresses.all()} + + # Global container should return all children + self.assertSetEqual(child_ip_pks, {ips[0].pk, ips[1].pk, ips[2].pk, ips[3].pk}) + + parent_prefix.vrf = vrfs[0] + parent_prefix.save() + + parent_prefix.refresh_from_db() + child_ip_pks = {p.pk for p in parent_prefix.ip_addresses.all()} + + # VRF container is limited to its own VRF + self.assertSetEqual(child_ip_pks, {ips[1].pk}) + + def test_parent_container_vrf_change(self): + vrfs = VRF.objects.bulk_create(( + VRF(name='VRF 1'), + VRF(name='VRF 2'), + VRF(name='VRF 3'), + )) + parent_prefix = Prefix.objects.create( + prefix=IPNetwork('10.0.0.0/16'), status=PrefixStatusChoices.STATUS_CONTAINER + ) + ips = IPAddress.objects.bulk_create(( + IPAddress(prefix=parent_prefix, address=IPNetwork('10.0.0.1/24'), vrf=None), + IPAddress(prefix=parent_prefix, address=IPNetwork('10.0.1.1/24'), vrf=vrfs[0]), + IPAddress(prefix=parent_prefix, address=IPNetwork('10.0.2.1/24'), vrf=vrfs[1]), + IPAddress(prefix=parent_prefix, address=IPNetwork('10.0.3.1/24'), vrf=vrfs[2]), + )) + child_ip_pks = {p.pk for p in parent_prefix.ip_addresses.all()} + + # Global container should return all children + self.assertSetEqual(child_ip_pks, {ips[0].pk, ips[1].pk, ips[2].pk, ips[3].pk}) + + parent_prefix.prefix = '10.0.0.0/25' + parent_prefix.save() + + parent_prefix.refresh_from_db() + child_ip_pks = {p.pk for p in parent_prefix.ip_addresses.all()} + + self.assertSetEqual(child_ip_pks, {ips[0].pk, ips[1].pk}) + class TestPrefixHierarchy(TestCase): """ diff --git a/netbox/ipam/triggers.py b/netbox/ipam/triggers.py index dd2983ac2..0b7c39beb 100644 --- a/netbox/ipam/triggers.py +++ b/netbox/ipam/triggers.py @@ -45,7 +45,7 @@ RETURN NEW; ipam_prefix_update_adjust_prefix_parent = """ --- When a prefix changes, reassign any IPAddresses that no longer +-- When a prefix changes, reassign any child prefixes that no longer -- fall within the new prefix range to the parent prefix (or set null if no parent exists) UPDATE ipam_prefix SET parent_id = OLD.parent_id @@ -54,38 +54,167 @@ WHERE -- IP address no longer contained within the updated prefix AND NOT (prefix << NEW.prefix); --- Update the prefix with the new parent if the parent is the most appropriate prefix -UPDATE ipam_prefix -SET parent_id=NEW.id +-- When a prefix changes, reassign any ip addresses that no longer +-- fall within the new prefix range to the parent prefix (or set null if no parent exists) +UPDATE ipam_ipaddress +SET prefix_id = OLD.parent_id WHERE - prefix << NEW.prefix + prefix_id = NEW.id + -- IP address no longer contained within the updated prefix AND - ( - (vrf_id = NEW.vrf_id OR (vrf_id IS NULL AND NEW.vrf_id IS NULL)) - OR + NOT (address << NEW.prefix) +; + +-- When a prefix changes, reassign any ip ranges that no longer +-- fall within the new prefix range to the parent prefix (or set null if no parent exists) +UPDATE ipam_iprange +SET prefix_id = OLD.parent_id +WHERE + prefix_id = NEW.id + -- IP address no longer contained within the updated prefix + AND + NOT (start_address << NEW.prefix) + AND + NOT (end_address << NEW.prefix) +; + +-- When a prefix changes, reassign any ip addresses that are in-scope but +-- no longer within the same VRF +UPDATE ipam_ipaddress + SET prefix_id = OLD.parent_id + WHERE + prefix_id = NEW.id + AND + address << OLD.prefix + AND ( - NEW.vrf_id IS NULL - AND - NEW.status = 'container' - AND - NOT EXISTS( - SELECT 1 FROM ipam_prefix p WHERE p.prefix >> ipam_prefix.prefix AND p.vrf_id = ipam_prefix.vrf_id + NOT address << NEW.prefix + OR + ( + vrf_id is NULL + AND + NEW.vrf_id IS NOT NULL + ) + OR + ( + OLD.vrf_id IS NULL + AND + NEW.vrf_id IS NOT NULL + AND + NEW.vrf_id != vrf_id ) ) - ) - AND id != NEW.id - AND NOT EXISTS ( - SELECT 1 FROM ipam_prefix p - WHERE - p.prefix >> ipam_prefix.prefix - AND p.prefix << NEW.prefix - AND ( - (p.vrf_id = ipam_prefix.vrf_id OR (p.vrf_id IS NULL AND ipam_prefix.vrf_id IS NULL)) - OR - (p.vrf_id IS NULL AND p.status = 'container') +; + +-- When a prefix changes, reassign any ip ranges that are in-scope but +-- no longer within the same VRF +UPDATE ipam_iprange + SET prefix_id = OLD.parent_id + WHERE + prefix_id = NEW.id + AND + start_address << OLD.prefix + AND + end_address << OLD.prefix + AND + ( + NOT start_address << NEW.prefix + OR + NOT end_address << NEW.prefix + OR + ( + vrf_id is NULL + AND + NEW.vrf_id IS NOT NULL ) - AND p.id != NEW.id - ) + OR + ( + OLD.vrf_id IS NULL + AND + NEW.vrf_id IS NOT NULL + AND + NEW.vrf_id != vrf_id + ) + ) +; + +-- Update the prefix with the new parent if the parent is the most appropriate prefix +UPDATE ipam_prefix + SET parent_id=NEW.id + WHERE + prefix << NEW.prefix + AND + ( + (vrf_id = NEW.vrf_id OR (vrf_id IS NULL AND NEW.vrf_id IS NULL)) + OR + ( + NEW.vrf_id IS NULL + AND + NEW.status = 'container' + AND + NOT EXISTS( + SELECT 1 FROM ipam_prefix p WHERE p.prefix >> prefix AND p.vrf_id = vrf_id + ) + ) + ) + AND id != NEW.id + AND NOT EXISTS ( + SELECT 1 FROM ipam_prefix p + WHERE + p.prefix >> ipam_prefix.prefix + AND p.prefix << NEW.prefix + AND ( + (p.vrf_id = ipam_prefix.vrf_id OR (p.vrf_id IS NULL AND ipam_prefix.vrf_id IS NULL)) + OR + (p.vrf_id IS NULL AND p.status = 'container') + ) + AND p.id != NEW.id + ) +; +UPDATE ipam_ipaddress + SET prefix_id = NEW.id + WHERE + prefix_id != NEW.id + AND + address << NEW.prefix + AND ( + (vrf_id = NEW.vrf_id OR (vrf_id IS NULL AND NEW.vrf_id IS NULL)) + OR ( + NEW.vrf_id IS NULL + AND + NEW.status = 'container' + AND + NOT EXISTS( + SELECT 1 FROM ipam_prefix p WHERE p.prefix >> address AND p.vrf_id = vrf_id + ) + ) + ) +; +UPDATE ipam_iprange + SET prefix_id = NEW.id + WHERE + prefix_id != NEW.id + AND + start_address << NEW.prefix + AND + end_address << NEW.prefix + AND ( + (vrf_id = NEW.vrf_id OR (vrf_id IS NULL AND NEW.vrf_id IS NULL)) + OR ( + NEW.vrf_id IS NULL + AND + NEW.status = 'container' + AND + NOT EXISTS( + SELECT 1 FROM ipam_prefix p WHERE + p.prefix >> start_address + AND + p.prefix >> end_address + AND + p.vrf_id = vrf_id + ) + ) + ) ; RETURN NEW; """