@@ -223,6 +223,126 @@ def remove_unused_ssh_key_pairs(client, executor_name_part):
223
223
}))
224
224
225
225
226
+ def cleanup_orphaned_eips (ec2_client , executor_name_part ):
227
+ """
228
+ Clean up orphaned EIPs from terminated instances.
229
+ :param ec2_client: the boto3 EC2 client
230
+ :param executor_name_part: used to filter EIPs by Environment tag to match this value
231
+ """
232
+ print (json .dumps ({
233
+ "Level" : "info" ,
234
+ "Message" : f"Checking for orphaned EIPs for agent { executor_name_part } "
235
+ }))
236
+
237
+ try :
238
+ # Find all EIPs (we'll filter by tag content below)
239
+ eips_response = ec2_client .describe_addresses ()
240
+
241
+ eips_to_cleanup = []
242
+
243
+ for eip in eips_response .get ("Addresses" , []):
244
+ allocation_id = eip ["AllocationId" ]
245
+ instance_id = eip .get ("InstanceId" )
246
+
247
+ # First check if this EIP belongs to our environment
248
+ eip_tags = {tag ["Key" ]: tag ["Value" ] for tag in eip .get ("Tags" , [])}
249
+ if not ("Environment" in eip_tags and executor_name_part in eip_tags ["Environment" ]):
250
+ continue # Skip EIPs not belonging to our environment
251
+
252
+ if instance_id :
253
+ # Check if the associated instance still exists and is terminated
254
+ try :
255
+ instance_response = ec2_client .describe_instances (InstanceIds = [instance_id ])
256
+ instance_state = instance_response ["Reservations" ][0 ]["Instances" ][0 ]["State" ]["Name" ]
257
+
258
+ if instance_state == "terminated" :
259
+ eips_to_cleanup .append ({
260
+ "allocation_id" : allocation_id ,
261
+ "instance_id" : instance_id ,
262
+ "public_ip" : eip .get ("PublicIp" , "unknown" ),
263
+ "reason" : f"associated instance { instance_id } is terminated"
264
+ })
265
+ except ClientError as error :
266
+ if 'InvalidInstanceID.NotFound' in str (error ):
267
+ # Instance no longer exists
268
+ eips_to_cleanup .append ({
269
+ "allocation_id" : allocation_id ,
270
+ "instance_id" : instance_id ,
271
+ "public_ip" : eip .get ("PublicIp" , "unknown" ),
272
+ "reason" : f"associated instance { instance_id } no longer exists"
273
+ })
274
+ else :
275
+ print (json .dumps ({
276
+ "Level" : "warning" ,
277
+ "Message" : f"Could not check instance { instance_id } for EIP { allocation_id } " ,
278
+ "Exception" : str (error )
279
+ }))
280
+ else :
281
+ # EIP is not associated with any instance and belongs to our environment
282
+ eips_to_cleanup .append ({
283
+ "allocation_id" : allocation_id ,
284
+ "instance_id" : "none" ,
285
+ "public_ip" : eip .get ("PublicIp" , "unknown" ),
286
+ "reason" : "unassociated EIP with matching Environment tag"
287
+ })
288
+
289
+ # Clean up identified orphaned EIPs
290
+ for eip_info in eips_to_cleanup :
291
+ try :
292
+ print (json .dumps ({
293
+ "Level" : "info" ,
294
+ "AllocationId" : eip_info ["allocation_id" ],
295
+ "PublicIp" : eip_info ["public_ip" ],
296
+ "Message" : f"Releasing orphaned EIP: { eip_info ['reason' ]} "
297
+ }))
298
+
299
+ # Disassociate first if still associated
300
+ if eip_info ["instance_id" ] != "none" :
301
+ try :
302
+ ec2_client .disassociate_address (AllocationId = eip_info ["allocation_id" ])
303
+ except ClientError as disassociate_error :
304
+ print (json .dumps ({
305
+ "Level" : "warning" ,
306
+ "Message" : f"Failed to disassociate EIP { eip_info ['allocation_id' ]} " ,
307
+ "Exception" : str (disassociate_error )
308
+ }))
309
+
310
+ # Release the EIP
311
+ ec2_client .release_address (AllocationId = eip_info ["allocation_id" ])
312
+
313
+ print (json .dumps ({
314
+ "Level" : "info" ,
315
+ "AllocationId" : eip_info ["allocation_id" ],
316
+ "Message" : "Successfully released orphaned EIP"
317
+ }))
318
+
319
+ except ClientError as error :
320
+ print (json .dumps ({
321
+ "Level" : "error" ,
322
+ "AllocationId" : eip_info ["allocation_id" ],
323
+ "Message" : f"Failed to release orphaned EIP" ,
324
+ "Exception" : str (error )
325
+ }))
326
+
327
+ if not eips_to_cleanup :
328
+ print (json .dumps ({
329
+ "Level" : "info" ,
330
+ "Message" : "No orphaned EIPs found to clean up"
331
+ }))
332
+ else :
333
+ print (json .dumps ({
334
+ "Level" : "info" ,
335
+ "Message" : f"Cleaned up { len (eips_to_cleanup )} orphaned EIP(s)"
336
+ }))
337
+
338
+ except ClientError as error :
339
+ print (json .dumps ({
340
+ "Level" : "error" ,
341
+ "Message" : "Failed to describe EIPs for cleanup" ,
342
+ "Exception" : str (error )
343
+ }))
344
+
345
+
226
346
# context not used: this is the interface for a AWS Lambda function defined by AWS
227
347
# pylint: disable=unused-argument
228
348
def handler (event , context ):
@@ -269,6 +389,9 @@ def handler(event, context):
269
389
270
390
remove_unused_ssh_key_pairs (client = client , executor_name_part = os .environ ['NAME_EXECUTOR_INSTANCE' ])
271
391
392
+ # Clean up orphaned EIPs from terminated instances
393
+ cleanup_orphaned_eips (ec2_client = client , executor_name_part = os .environ ['NAME_EXECUTOR_INSTANCE' ])
394
+
272
395
return "Housekeeping done"
273
396
274
397
0 commit comments