Skip to content

Commit 4f809c6

Browse files
authored
Merge pull request #1 from stackopshq/fix/cleanup-router-network-deps
fix(project-cleanup): robust router + network teardown
2 parents ec9ca5a + 10d5cbd commit 4f809c6

2 files changed

Lines changed: 220 additions & 16 deletions

File tree

orca_cli/commands/project.py

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,67 @@ def _before_cutoff(resource: dict, cutoff: datetime | None) -> bool:
187187
return True
188188

189189

190+
# device_owner values for ports attached to a router that need to be detached
191+
# via remove_router_interface before the router can be deleted. Router gateway
192+
# ports (network:router_gateway, network:router_centralized_snat) are released
193+
# by clearing external_gateway_info instead.
194+
_ROUTER_INTERFACE_OWNERS = (
195+
"network:router_interface",
196+
"network:router_interface_distributed",
197+
"network:ha_router_replicated_interface",
198+
)
199+
200+
201+
def _delete_router(client, rid: str) -> None:
202+
"""Detach all router interfaces and clear the external gateway, then delete."""
203+
net_svc = NetworkService(client)
204+
205+
# Clear external gateway (releases router_gateway / centralized_snat ports).
206+
try:
207+
net_svc.update_router(rid, {"external_gateway_info": None})
208+
except Exception:
209+
pass
210+
211+
# Detach every router-interface port, regardless of legacy/DVR/HA flavor.
212+
try:
213+
ports = net_svc.find_ports(params={"device_id": rid})
214+
except Exception:
215+
ports = []
216+
for p in ports:
217+
owner = p.get("device_owner", "")
218+
if not any(owner.startswith(o) for o in _ROUTER_INTERFACE_OWNERS):
219+
continue
220+
try:
221+
net_svc.remove_router_interface(rid, {"port_id": p["id"]})
222+
except Exception:
223+
pass
224+
225+
net_svc.delete_router(rid)
226+
227+
228+
def _delete_network(client, nid: str) -> None:
229+
"""Delete orphan/compute leftover ports, then delete the network."""
230+
net_svc = NetworkService(client)
231+
232+
# Ports owned by Neutron itself (dhcp, router, floatingip, ha_*) are cleaned
233+
# up by their respective delete paths; we only remove truly orphaned ports
234+
# or stale compute VIFs whose server has already been deleted.
235+
try:
236+
ports = net_svc.find_ports(params={"network_id": nid})
237+
except Exception:
238+
ports = []
239+
for p in ports:
240+
owner = p.get("device_owner", "")
241+
if owner and not owner.startswith("compute:"):
242+
continue
243+
try:
244+
net_svc.delete_port(p["id"])
245+
except Exception:
246+
pass
247+
248+
net_svc.delete(nid)
249+
250+
190251
def _delete_one(client, rtype: str, rid: str, rname: str) -> bool:
191252
"""Delete a single resource by type. Returns True on success."""
192253
label = f"{rtype} {rname} ({rid})"
@@ -200,23 +261,9 @@ def _delete_one(client, rtype: str, rid: str, rname: str) -> bool:
200261
elif rtype == "floating-ip":
201262
NetworkService(client).delete_floating_ip(rid)
202263
elif rtype == "router":
203-
# Detach all subnet interfaces before deleting
204-
net_svc = NetworkService(client)
205-
try:
206-
ports = net_svc.find_ports(params={
207-
"device_id": rid,
208-
"device_owner": "network:router_interface",
209-
})
210-
except Exception:
211-
ports = []
212-
for p in ports:
213-
try:
214-
net_svc.remove_router_interface(rid, {"port_id": p["id"]})
215-
except Exception:
216-
pass
217-
net_svc.delete_router(rid)
264+
_delete_router(client, rid)
218265
elif rtype == "network":
219-
NetworkService(client).delete(rid)
266+
_delete_network(client, rid)
220267
elif rtype == "security-group":
221268
NetworkService(client).delete_security_group(rid)
222269
elif rtype == "volume":

tests/test_project_cleanup.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""Tests for ``orca project cleanup`` helpers handling router and network deps."""
2+
3+
from __future__ import annotations
4+
5+
from unittest.mock import MagicMock
6+
7+
from orca_cli.commands import project as proj_mod
8+
9+
10+
def _make_net_svc(monkeypatch, **attrs):
11+
net_svc = MagicMock()
12+
for k, v in attrs.items():
13+
setattr(net_svc, k, v)
14+
monkeypatch.setattr(proj_mod, "NetworkService", lambda _c: net_svc)
15+
return net_svc
16+
17+
18+
class TestDeleteRouter:
19+
def test_clears_gateway_before_detaching_and_deleting(self, monkeypatch):
20+
net = _make_net_svc(monkeypatch)
21+
net.find_ports.return_value = []
22+
23+
proj_mod._delete_router(object(), "r-1")
24+
25+
net.update_router.assert_called_once_with(
26+
"r-1", {"external_gateway_info": None}
27+
)
28+
net.delete_router.assert_called_once_with("r-1")
29+
30+
def test_detaches_all_router_interface_flavors(self, monkeypatch):
31+
net = _make_net_svc(monkeypatch)
32+
net.find_ports.return_value = [
33+
{"id": "p-legacy", "device_owner": "network:router_interface"},
34+
{"id": "p-dvr",
35+
"device_owner": "network:router_interface_distributed"},
36+
{"id": "p-ha",
37+
"device_owner": "network:ha_router_replicated_interface"},
38+
]
39+
40+
proj_mod._delete_router(object(), "r-1")
41+
42+
assert net.remove_router_interface.call_count == 3
43+
detached = {
44+
c.args[1]["port_id"]
45+
for c in net.remove_router_interface.call_args_list
46+
}
47+
assert detached == {"p-legacy", "p-dvr", "p-ha"}
48+
49+
def test_ignores_non_interface_ports(self, monkeypatch):
50+
"""Gateway / SNAT / foreign ports must not go through remove_router_interface."""
51+
net = _make_net_svc(monkeypatch)
52+
net.find_ports.return_value = [
53+
{"id": "p-gw", "device_owner": "network:router_gateway"},
54+
{"id": "p-snat", "device_owner": "network:router_centralized_snat"},
55+
{"id": "p-dhcp", "device_owner": "network:dhcp"},
56+
{"id": "p-orphan", "device_owner": ""},
57+
]
58+
59+
proj_mod._delete_router(object(), "r-1")
60+
61+
net.remove_router_interface.assert_not_called()
62+
net.delete_router.assert_called_once_with("r-1")
63+
64+
def test_lists_ports_by_device_id_only(self, monkeypatch):
65+
"""Broader filter: device_owner is applied Python-side, not in the query."""
66+
net = _make_net_svc(monkeypatch)
67+
net.find_ports.return_value = []
68+
69+
proj_mod._delete_router(object(), "r-xyz")
70+
71+
net.find_ports.assert_called_once_with(params={"device_id": "r-xyz"})
72+
73+
def test_continues_when_update_router_fails(self, monkeypatch):
74+
"""Some clouds reject gateway clear if no gateway set — must not abort."""
75+
net = _make_net_svc(monkeypatch)
76+
net.update_router.side_effect = RuntimeError("no gateway")
77+
net.find_ports.return_value = [
78+
{"id": "p1", "device_owner": "network:router_interface"},
79+
]
80+
81+
proj_mod._delete_router(object(), "r-1")
82+
83+
net.remove_router_interface.assert_called_once_with(
84+
"r-1", {"port_id": "p1"}
85+
)
86+
net.delete_router.assert_called_once_with("r-1")
87+
88+
def test_continues_when_find_ports_fails(self, monkeypatch):
89+
net = _make_net_svc(monkeypatch)
90+
net.find_ports.side_effect = RuntimeError("neutron down")
91+
92+
proj_mod._delete_router(object(), "r-1")
93+
94+
net.remove_router_interface.assert_not_called()
95+
net.delete_router.assert_called_once_with("r-1")
96+
97+
def test_continues_when_remove_interface_fails(self, monkeypatch):
98+
net = _make_net_svc(monkeypatch)
99+
net.find_ports.return_value = [
100+
{"id": "p1", "device_owner": "network:router_interface"},
101+
{"id": "p2", "device_owner": "network:router_interface"},
102+
]
103+
net.remove_router_interface.side_effect = [RuntimeError("boom"), None]
104+
105+
proj_mod._delete_router(object(), "r-1")
106+
107+
assert net.remove_router_interface.call_count == 2
108+
net.delete_router.assert_called_once_with("r-1")
109+
110+
111+
class TestDeleteNetwork:
112+
def test_deletes_orphan_and_stale_compute_ports(self, monkeypatch):
113+
net = _make_net_svc(monkeypatch)
114+
net.find_ports.return_value = [
115+
{"id": "p-orphan", "device_owner": ""},
116+
{"id": "p-vif", "device_owner": "compute:nova"},
117+
{"id": "p-dhcp", "device_owner": "network:dhcp"},
118+
{"id": "p-router", "device_owner": "network:router_interface"},
119+
{"id": "p-fip", "device_owner": "network:floatingip"},
120+
]
121+
122+
proj_mod._delete_network(object(), "n-1")
123+
124+
deleted = {c.args[0] for c in net.delete_port.call_args_list}
125+
assert deleted == {"p-orphan", "p-vif"}
126+
net.delete.assert_called_once_with("n-1")
127+
128+
def test_lists_ports_by_network_id(self, monkeypatch):
129+
net = _make_net_svc(monkeypatch)
130+
net.find_ports.return_value = []
131+
132+
proj_mod._delete_network(object(), "n-42")
133+
134+
net.find_ports.assert_called_once_with(params={"network_id": "n-42"})
135+
net.delete.assert_called_once_with("n-42")
136+
137+
def test_continues_when_port_delete_fails(self, monkeypatch):
138+
net = _make_net_svc(monkeypatch)
139+
net.find_ports.return_value = [
140+
{"id": "p1", "device_owner": ""},
141+
{"id": "p2", "device_owner": "compute:nova"},
142+
]
143+
net.delete_port.side_effect = [RuntimeError("boom"), None]
144+
145+
proj_mod._delete_network(object(), "n-1")
146+
147+
assert net.delete_port.call_count == 2
148+
net.delete.assert_called_once_with("n-1")
149+
150+
def test_continues_when_find_ports_fails(self, monkeypatch):
151+
net = _make_net_svc(monkeypatch)
152+
net.find_ports.side_effect = RuntimeError("neutron down")
153+
154+
proj_mod._delete_network(object(), "n-1")
155+
156+
net.delete_port.assert_not_called()
157+
net.delete.assert_called_once_with("n-1")

0 commit comments

Comments
 (0)