Skip to content

Commit db1b877

Browse files
nikromenpraiskup
authored andcommitted
powervs: fix ports cleaning by cleaning them manually on delete
1 parent 37f2293 commit db1b877

7 files changed

Lines changed: 200 additions & 8 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ resalloc-ibm-cloud-vm = "resalloc_ibm_cloud.ibm_cloud_vm:main"
2727
resalloc-ibm-cloud-powervs-vm = "resalloc_ibm_cloud.powervs.powervs_vm:main"
2828
resalloc-ibm-cloud-powervs-list-vms = "resalloc_ibm_cloud.powervs.powervs_list_vms:main"
2929
resalloc-ibm-cloud-powervs-list-deleting-vms = "resalloc_ibm_cloud.powervs.powervs_list_deleting_vms:main"
30+
resalloc-ibm-cloud-powervs-cleanup-ips = "resalloc_ibm_cloud.powervs.powervs_cleanup_ips:main"
3031

3132

3233
[build-system]
@@ -47,4 +48,5 @@ manpages = [
4748
"man/resalloc-ibm-cloud-powervs-vm.1:function=powervs_arg_parser:pyfile=resalloc_ibm_cloud/argparsers.py",
4849
"man/resalloc-ibm-cloud-powervs-list-vms.1:function=powervs_list_vms_parser:pyfile=resalloc_ibm_cloud/argparsers.py",
4950
"man/resalloc-ibm-cloud-powervs-list-deleting-vms.1:function=powervs_list_deleting_vms_parser:pyfile=resalloc_ibm_cloud/argparsers.py",
51+
"man/resalloc-ibm-cloud-powervs-cleanup-ips.1:function=powervs_cleanup_ips_parser:pyfile=resalloc_ibm_cloud/argparsers.py",
5052
]

resalloc-ibm-cloud.spec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ BuildRequires: pyproject-rpm-macros
5252
%{_bindir}/resalloc-ibm-cloud-powervs-list-deleting-vms
5353
%{_bindir}/resalloc-ibm-cloud-powervs-list-vms
5454
%{_bindir}/resalloc-ibm-cloud-powervs-vm
55+
%{_bindir}/resalloc-ibm-cloud-powervs-cleanup-ips
5556

5657

5758
%changelog

resalloc_ibm_cloud/argparsers.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,3 +247,18 @@ def powervs_list_deleting_vms_parser():
247247
"""
248248
parser = _default_arg_parser_powervs(prog=_pfx("powervs-list-deleting-vms"))
249249
return parser
250+
251+
252+
def powervs_cleanup_ips_parser():
253+
"""
254+
Parser for the resalloc-ibm-cloud-powervs-cleanup-ips utility.
255+
"""
256+
parser = _default_arg_parser_powervs(prog=_pfx("powervs-cleanup-ips"))
257+
parser.add_argument(
258+
"--network-id",
259+
type=str,
260+
nargs="+",
261+
required=True,
262+
help="One or more network (subnet) IDs to clean up orphaned IPs from",
263+
)
264+
return parser

resalloc_ibm_cloud/powervs/client.py

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ def request(
6363
params: dict = None,
6464
json_data: dict = None,
6565
broker: bool = False,
66+
v1_api: bool = False,
6667
) -> dict:
6768
"""
6869
Make a request to the PowerVS API with automatic retry for server errors
@@ -78,6 +79,8 @@ def request(
7879
params: Query parameters
7980
json_data: JSON body data
8081
broker: Whether to use the broker API
82+
v1_api: Whether to use the newer /v1 API (endpoints migrated
83+
from /pcloud/v1); these don't use the cloud-instance prefix
8184
8285
Returns:
8386
Response JSON
@@ -88,12 +91,15 @@ def request(
8891
requests.RequestException: For other request-related errors after
8992
retries are exhausted
9093
"""
91-
base_url = (
92-
self.credentials.broker_url if broker else self.credentials.service_url
93-
)
94-
95-
# set prefix path for powervs API workspace
96-
if not broker and not path.startswith(
94+
if v1_api:
95+
base_url = self.credentials.v1_url
96+
elif broker:
97+
base_url = self.credentials.broker_url
98+
else:
99+
base_url = self.credentials.service_url
100+
101+
# set prefix path for powervs API workspace (legacy /pcloud/v1 only)
102+
if not broker and not v1_api and not path.startswith(
97103
f"/cloud-instances/{self.cloud_instance_id}"
98104
):
99105
path = f"/cloud-instances/{self.cloud_instance_id}{path}"
@@ -264,3 +270,40 @@ def list_volumes(self) -> list[dict]:
264270
List of volumes
265271
"""
266272
return self.request("GET", "/volumes").get("volumes", [])
273+
274+
def list_network_interfaces(self, network_id: str) -> list[dict]:
275+
"""
276+
List all network interfaces (ports) on a given network.
277+
278+
Args:
279+
network_id: Network (subnet) ID
280+
281+
Returns:
282+
List of network interfaces
283+
"""
284+
response = self.request(
285+
"GET",
286+
f"/networks/{network_id}/network-interfaces",
287+
v1_api=True,
288+
)
289+
return response.get("interfaces", [])
290+
291+
def delete_network_interface(
292+
self, network_id: str, interface_id: str
293+
) -> None:
294+
"""
295+
Delete a network interface (port) from a network.
296+
297+
Args:
298+
network_id: Network (subnet) ID
299+
interface_id: Network interface ID to delete
300+
"""
301+
logger.info(
302+
"Deleting network interface %s from network %s",
303+
interface_id, network_id,
304+
)
305+
self.request(
306+
"DELETE",
307+
f"/networks/{network_id}/network-interfaces/{interface_id}",
308+
v1_api=True,
309+
)

resalloc_ibm_cloud/powervs/credentials.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,14 @@ def iaas_url(self) -> str:
3232

3333
@property
3434
def service_url(self) -> str:
35-
"""PowerVS service URL for the region"""
35+
"""PowerVS service URL for the region (/pcloud/v1 API path)"""
3636
return f"{self.iaas_url}/pcloud/v1"
3737

38+
@property
39+
def v1_url(self) -> str:
40+
"""PowerVS v1 API URL (/v1 - totally different API - no pcloud prefix)"""
41+
return f"{self.iaas_url}/v1"
42+
3843
@property
3944
def broker_url(self) -> str:
4045
"""PowerVS broker URL"""
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""
2+
Helper script for cleaning up orphaned network interfaces (ports) from
3+
PowerVS subnets.
4+
"""
5+
6+
import logging
7+
8+
from resalloc_ibm_cloud.argparsers import powervs_cleanup_ips_parser
9+
from resalloc_ibm_cloud.helpers import setup_logging
10+
from resalloc_ibm_cloud.powervs.credentials import get_powervs_credentials
11+
from resalloc_ibm_cloud.powervs.client import PowerVSClient
12+
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
def _get_active_instance_ids(client: PowerVSClient) -> set[str]:
18+
instances = client.list_instances()
19+
return {inst["pvmInstanceID"] for inst in instances if inst.get("pvmInstanceID")}
20+
21+
22+
def cleanup_network_interfaces(
23+
client: PowerVSClient,
24+
network_ids: list[str],
25+
) -> None:
26+
"""
27+
Delete orphaned network interfaces (ports) that are not attached to any
28+
active VM instance.
29+
"""
30+
active_ids = _get_active_instance_ids(client)
31+
32+
logger.info("Found %d active instance(s)", len(active_ids))
33+
34+
for network_id in network_ids:
35+
interfaces = client.list_network_interfaces(network_id)
36+
logger.info(
37+
"Network %s: found %d network interface(s)",
38+
network_id, len(interfaces),
39+
)
40+
41+
orphaned = 0
42+
for iface in interfaces:
43+
instance_ref = iface.get("instance")
44+
instance_id = instance_ref.get("instanceID") if instance_ref else None
45+
if instance_id and instance_id in active_ids:
46+
continue
47+
48+
orphaned += 1
49+
logger.info(
50+
"Deleting orphaned network interface %s (ip=%s) from network %s",
51+
iface["id"], iface.get("ipAddress", "?"), network_id,
52+
)
53+
54+
client.delete_network_interface(network_id, iface["id"])
55+
56+
logger.info(
57+
"Network %s: deleted %d orphaned interface(s)", network_id, orphaned,
58+
)
59+
60+
61+
def main() -> None:
62+
"""Entrypoint to the script."""
63+
opts = powervs_cleanup_ips_parser().parse_args()
64+
setup_logging(opts.log_level)
65+
66+
credentials = get_powervs_credentials(opts.token_file, opts.crn)
67+
client = PowerVSClient(credentials)
68+
cleanup_network_interfaces(client, opts.network_id)

resalloc_ibm_cloud/powervs/powervs_vm.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from typing import Any, Optional
1111

1212
import backoff
13-
from requests import HTTPError
13+
from requests import HTTPError, RequestException
1414
import requests
1515

1616
from resalloc_ibm_cloud.argparsers import powervs_arg_parser
@@ -162,6 +162,39 @@ def _delete_volume_with_backoff(self, volume_id: str) -> None:
162162
self.client.delete_volume(volume_id)
163163
logger.info("Deleted volume with ID %s", volume_id)
164164

165+
def _wait_for_instance_gone(
166+
self, instance_id: str, timeout: int = 600, interval: int = 10,
167+
) -> None:
168+
"""
169+
Poll until the instance no longer exists (404) so that its network
170+
interfaces are safe to delete.
171+
"""
172+
start_time = time.time()
173+
while True:
174+
if time.time() - start_time > timeout:
175+
logger.warning(
176+
"Timed out waiting for instance %s to be fully deleted",
177+
instance_id,
178+
)
179+
break
180+
181+
try:
182+
instance = self.client.get_instance(instance_id)
183+
status = instance.get("status", "unknown")
184+
logger.info(
185+
"Waiting for instance %s to be deleted (status: %s)",
186+
instance_id, status,
187+
)
188+
except RequestException as e:
189+
if e.response.status_code == 404:
190+
logger.info("Instance %s is gone", instance_id)
191+
return
192+
193+
logger.error("Failed to get instance %s: %s", instance_id, str(e))
194+
break
195+
196+
sleep(interval)
197+
165198
def _force_delete_volume_by_instance_name(self, instance_name: str) -> None:
166199
# if powervs decides to fail and keep the volume around, force delete any
167200
# volume that starts with the instance name
@@ -198,6 +231,18 @@ def delete_vm(self, name: str) -> None:
198231
instance_information = self.client.get_instance(instance_id)
199232
volume_ids = instance_information.get("volumeIDs", [])
200233

234+
# IBM Cloud does not automatically clean up network interfaces (ports)
235+
# when a VM is deleted, which eventually exhausts the subnet IP pool.
236+
# We need to remember them now and delete them after the instance is gone.
237+
# This is a new thing in PowerVS, maybe in the future it will be handled automatically
238+
# again???
239+
network_interfaces = []
240+
for net in instance_information.get("networks", []):
241+
network_id = net.get("networkID")
242+
interface_id = net.get("networkInterfaceID")
243+
if network_id and interface_id:
244+
network_interfaces.append((network_id, interface_id))
245+
201246
# the data volumes tends to remain undeleted even if the delete_instance
202247
# call is with delete_data_volumes, so this needs to be assured manually
203248
for volume_id in volume_ids:
@@ -218,6 +263,19 @@ def delete_vm(self, name: str) -> None:
218263
instance_id,
219264
)
220265

266+
if not network_interfaces:
267+
return
268+
269+
self._wait_for_instance_gone(instance_id)
270+
for network_id, interface_id in network_interfaces:
271+
try:
272+
self.client.delete_network_interface(network_id, interface_id)
273+
except RequestException as e:
274+
logger.error(
275+
"Failed to delete network interface %s: %s",
276+
interface_id, str(e),
277+
)
278+
221279
def _parse_volumes(self, volumes_list: list[str]) -> list[dict]:
222280
"""
223281
Parse volume specifications from a list of strings.

0 commit comments

Comments
 (0)