Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -296,10 +296,9 @@ def _tenant_network_port_cleanup(self, context: PortContext):
segment_id = context.original_top_bound_segment["id"]
original_binding = context.original[portbindings.PROFILE]

if not utils.ports_bound_to_segment(
segment_id
) and utils.is_dynamic_network_segment(segment_id):
context.release_dynamic_segment(segment_id)
segment = utils.network_segment_by_id(segment_id)
if segment:
utils.release_segment_if_unused(segment)

networks_to_remove = {segment_id}

Expand All @@ -323,9 +322,6 @@ def delete_port_postcommit(self, context: PortContext) -> None:
self._delete_port_baremetal(context)

def _delete_port_baremetal(self, context: PortContext) -> None:
# Only clean up provisioning ports. Ports with tenant networks are cleaned
# up in _tenant_network_port_cleanup

port = context.current

vlan_group_name = port[portbindings.PROFILE].get("physical_network")
Expand All @@ -337,10 +333,27 @@ def _delete_port_baremetal(self, context: PortContext) -> None:
local_link_info
)

if vlan_group_name and is_provisioning_network(port["network_id"]):
if not vlan_group_name:
return

if is_provisioning_network(port["network_id"]):
# Signals end of the provisioning / cleaning cycle, so we
# put the port back to its normal tenant mode:
self.undersync.sync(vlan_group_name)
return

# A tenant network port normally has its dynamic VLAN segment released
# by _tenant_network_port_cleanup, but that only runs on the
# update_port_postcommit bound->unbound transition. A port deleted
# directly (still bound) skips that transition entirely, so without
# this the segment -- and its VLAN -- leaks forever.
segment = utils.network_segment_by_physnet(port["network_id"], vlan_group_name)
if segment:
utils.release_segment_if_unused(segment)

# Reconcile the switch so the removed port's VLAN config is torn down,
# matching the unbind path in _update_port_baremetal.
self.undersync.sync(vlan_group_name)

def bind_port(self, context: PortContext) -> None:
"""Bind the VXLAN network segment and allocate dynamic VLAN segments.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ def test_skips_non_baremetal_port(self, understack_driver, port_context):
understack_driver.undersync.sync.assert_not_called()


MECH_UTILS = "neutron_understack.neutron_understack_mech.utils"


@pytest.mark.usefixtures("_ironic_baremetal_port_physical_network")
class TestDeletePortPostCommit:
def test_skips_non_baremetal_port(self, understack_driver, port_context):
port_context.current[portbindings.VNIC_TYPE] = portbindings.VNIC_NORMAL
Expand All @@ -158,6 +162,79 @@ def test_skips_non_baremetal_port(self, understack_driver, port_context):

understack_driver.undersync.sync.assert_not_called()

def test_releases_unused_dynamic_segment_on_tenant_network(
self, mocker, understack_driver, port_context
):
"""Deleting a still-bound tenant port must release its dynamic segment.

This is the only way it gets released: unlike an explicit unbind
(update_port_postcommit -> _tenant_network_port_cleanup), a direct
delete never goes through that transition.
"""
segment = mocker.Mock(id="segment-a", is_dynamic=True)
find_segment = mocker.patch(
f"{MECH_UTILS}.network_segment_by_physnet", return_value=segment
)
mocker.patch(f"{MECH_UTILS}.ports_bound_to_segment", return_value=[])
release = mocker.patch(f"{MECH_UTILS}.release_dynamic_segment")

understack_driver.delete_port_postcommit(port_context)

find_segment.assert_called_once_with(
port_context.current["network_id"], "physnet"
)
release.assert_called_once_with("segment-a")
# The switch must be reconciled so the removed port's VLAN is torn down.
understack_driver.undersync.sync.assert_called_once_with("physnet")

def test_keeps_segment_still_bound_to_other_ports(
self, mocker, understack_driver, port_context
):
segment = mocker.Mock(id="segment-a")
mocker.patch(f"{MECH_UTILS}.network_segment_by_physnet", return_value=segment)
mocker.patch(
f"{MECH_UTILS}.ports_bound_to_segment", return_value=["other-port"]
)
release = mocker.patch(f"{MECH_UTILS}.release_dynamic_segment")

understack_driver.delete_port_postcommit(port_context)

release.assert_not_called()

def test_keeps_non_dynamic_segment(self, mocker, understack_driver, port_context):
segment = mocker.Mock(id="segment-a", is_dynamic=False)
mocker.patch(f"{MECH_UTILS}.network_segment_by_physnet", return_value=segment)
mocker.patch(f"{MECH_UTILS}.ports_bound_to_segment", return_value=[])
release = mocker.patch(f"{MECH_UTILS}.release_dynamic_segment")

understack_driver.delete_port_postcommit(port_context)

release.assert_not_called()

def test_skips_release_when_no_segment_found(
self, mocker, understack_driver, port_context
):
mocker.patch(f"{MECH_UTILS}.network_segment_by_physnet", return_value=None)
release = mocker.patch(f"{MECH_UTILS}.release_dynamic_segment")

understack_driver.delete_port_postcommit(port_context)

release.assert_not_called()

def test_syncs_and_skips_release_on_provisioning_network(
self, mocker, understack_driver, port_context
):
mocker.patch(
"neutron_understack.neutron_understack_mech.is_provisioning_network",
return_value=True,
)
release = mocker.patch(f"{MECH_UTILS}.release_dynamic_segment")

understack_driver.delete_port_postcommit(port_context)

understack_driver.undersync.sync.assert_called_once_with("physnet")
release.assert_not_called()


@pytest.mark.usefixtures("_ironic_baremetal_port_physical_network")
class TestBindPort:
Expand Down
6 changes: 6 additions & 0 deletions python/neutron-understack/neutron_understack/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,12 @@ def is_dynamic_network_segment(segment_id: str) -> bool:
return segment.is_dynamic


def release_segment_if_unused(segment: NetworkSegment) -> None:
"""Release a dynamic VLAN segment once no ports remain bound to it."""
if not ports_bound_to_segment(segment.id) and segment.is_dynamic:
release_dynamic_segment(segment.id)


def local_link_from_binding_profile(binding_profile: dict) -> dict | None:
return binding_profile.get("local_link_information", [None])[0]

Expand Down
Loading