8383
8484_VM_CONTRIBUTOR_ROLE_DEFINITION_ID = "9980e02c-c2be-4d73-94e8-173b1dc7cf3c"
8585
86+ _NIC_NAME_PREFIX = "transformerlab-nic-"
87+ _PIP_NAME_PREFIX = "transformerlab-pip-"
88+
89+ # Azure only releases a NIC for deletion once its VM is fully gone, and a public IP
90+ # once its NIC is gone — both report "in use" errors for a while after the parent's
91+ # delete has already completed. Sleep this schedule between attempts.
92+ _NETWORK_DELETE_RETRY_DELAYS_SECONDS : tuple = (5 , 10 , 15 , 30 , 30 )
93+
8694
8795def _resolve_gpu_vm_size (accelerators : str ) -> str :
8896 """Map an accelerator spec (e.g. 'A100:8') to an Azure VM size.
@@ -212,18 +220,53 @@ def _get_authorization_client(self) -> Any:
212220
213221 return AuthorizationManagementClient (self ._get_credential (), self .subscription_id )
214222
223+ @staticmethod
224+ def _is_not_found_error (exc : Exception ) -> bool :
225+ msg = str (exc ).lower ()
226+ return "resourcenotfound" in msg or "notfound" in msg or "not found" in msg
227+
228+ def _delete_network_resource_with_retries (self , begin_delete : Any , resource_name : str ) -> bool :
229+ """Delete a NIC or public IP, retrying while Azure still reports it in use.
230+
231+ Returns True once the resource is gone (deleted here or already absent).
232+ """
233+ max_attempts = len (_NETWORK_DELETE_RETRY_DELAYS_SECONDS ) + 1
234+ for attempt in range (1 , max_attempts + 1 ):
235+ try :
236+ begin_delete (self .resource_group , resource_name ).result ()
237+ return True
238+ except Exception as e :
239+ if self ._is_not_found_error (e ):
240+ return True
241+ if attempt < max_attempts :
242+ delay = _NETWORK_DELETE_RETRY_DELAYS_SECONDS [attempt - 1 ]
243+ logger .info (
244+ "Delete of %s failed (attempt %d/%d), retrying in %ds: %s" ,
245+ resource_name ,
246+ attempt ,
247+ max_attempts ,
248+ delay ,
249+ e ,
250+ )
251+ time .sleep (delay )
252+ else :
253+ logger .warning ("Failed to delete %s after %d attempts: %s" , resource_name , max_attempts , e )
254+ return False
255+
215256 def _delete_cluster_nic_and_public_ip (self , network_client : Any , cluster_name : str ) -> None :
216- """Delete per-cluster NIC and public IP (NIC first; tolerates missing resources)."""
217- nic_name = f"transformerlab-nic-{ cluster_name } "
218- pip_name = f"transformerlab-pip-{ cluster_name } "
219- try :
220- network_client .network_interfaces .begin_delete (self .resource_group , nic_name ).result ()
221- except Exception as e :
222- logger .warning ("Failed to delete NIC %s: %s" , nic_name , e )
223- try :
224- network_client .public_ip_addresses .begin_delete (self .resource_group , pip_name ).result ()
225- except Exception as e :
226- logger .warning ("Failed to delete Public IP %s: %s" , pip_name , e )
257+ """Delete the per-cluster NIC, then its public IP.
258+
259+ Azure requires this order: the NIC can only be deleted once the VM is fully
260+ gone, and the public IP only once the NIC is gone, so each delete retries
261+ across the window where the parent resource is still being released.
262+ """
263+ nic_name = f"{ _NIC_NAME_PREFIX } { cluster_name } "
264+ pip_name = f"{ _PIP_NAME_PREFIX } { cluster_name } "
265+ nic_gone = self ._delete_network_resource_with_retries (network_client .network_interfaces .begin_delete , nic_name )
266+ if not nic_gone :
267+ logger .warning ("Skipping delete of %s because %s still exists and is holding it" , pip_name , nic_name )
268+ return
269+ self ._delete_network_resource_with_retries (network_client .public_ip_addresses .begin_delete , pip_name )
227270
228271 def _ensure_vm_self_delete_role (self , vm : Any ) -> None :
229272 """Best-effort: grant VM identity rights to delete itself."""
@@ -583,8 +626,8 @@ async def _ensure_and_get_public_key() -> str:
583626 subnet_id , nsg_id = self ._ensure_networking (network_client , resource_client )
584627 public_key_str = asyncio .run (_ensure_and_get_public_key ())
585628
586- pip_name = f"transformerlab-pip- { cluster_name } "
587- nic_name = f"transformerlab-nic- { cluster_name } "
629+ pip_name = f"{ _PIP_NAME_PREFIX } { cluster_name } "
630+ nic_name = f"{ _NIC_NAME_PREFIX } { cluster_name } "
588631 launch_succeeded = False
589632 try :
590633 pip = network_client .public_ip_addresses .begin_create_or_update (
@@ -599,7 +642,14 @@ async def _ensure_and_get_public_key() -> str:
599642 {
600643 "location" : self .location ,
601644 "ip_configurations" : [
602- {"name" : "ipconfig1" , "subnet" : {"id" : subnet_id }, "public_ip_address" : {"id" : pip .id }}
645+ {
646+ "name" : "ipconfig1" ,
647+ "subnet" : {"id" : subnet_id },
648+ # delete_option: Azure deletes the public IP along with the VM
649+ # (the VM self-deletes on job completion, so the control plane
650+ # never gets a chance to clean up in that path).
651+ "public_ip_address" : {"id" : pip .id , "delete_option" : "Delete" },
652+ }
603653 ],
604654 "network_security_group" : {"id" : nsg_id },
605655 },
@@ -684,6 +734,14 @@ async def _ensure_and_get_public_key() -> str:
684734 raise RuntimeError (f"Failed to launch Azure VM: { last_error } " ) from last_error
685735 finally :
686736 if not launch_succeeded :
737+ # A failed create can still leave a VM resource behind (provisioning
738+ # failed), and the NIC can't be deleted while that VM holds it — so
739+ # remove the VM first.
740+ try :
741+ compute_client .virtual_machines .begin_delete (self .resource_group , cluster_name ).result ()
742+ except Exception as e :
743+ if not self ._is_not_found_error (e ):
744+ logger .warning ("Failed to delete partially-created VM %s: %s" , cluster_name , e )
687745 self ._delete_cluster_nic_and_public_ip (network_client , cluster_name )
688746
689747 def stop_cluster (self , cluster_name : str ) -> Dict [str , Any ]:
@@ -693,7 +751,9 @@ def stop_cluster(self, cluster_name: str) -> Dict[str, Any]:
693751 try :
694752 compute_client .virtual_machines .begin_delete (self .resource_group , cluster_name ).result ()
695753 except Exception as e :
696- return {"status" : "error" , "message" : str (e ), "cluster_name" : cluster_name }
754+ # The VM self-deletes on job completion; still clean up its NIC/public IP.
755+ if not self ._is_not_found_error (e ):
756+ return {"status" : "error" , "message" : str (e ), "cluster_name" : cluster_name }
697757
698758 self ._delete_cluster_nic_and_public_ip (network_client , cluster_name )
699759
0 commit comments