11# This file is part of pycloudlib. See LICENSE file for license information.
22"""Base class for all other clouds to provide consistent set of functions."""
33
4+ import dataclasses
45import enum
56import getpass
67import io
@@ -38,6 +39,36 @@ class ImageType(enum.Enum):
3839 PRO_FIPS = "Pro FIPS"
3940
4041
42+ @dataclasses .dataclass
43+ class ImageInfo :
44+ """Dataclass that represents an image on any given cloud."""
45+
46+ image_id : str
47+ image_name : str
48+
49+ def __str__ (self ):
50+ """Return a human readable string representation of the image."""
51+ return f"{ self .image_name } [id: { self .image_id } ]"
52+
53+ def __repr__ (self ):
54+ """Return a string representation of the image."""
55+ return f"ImageInfo(id={ self .image_id } , name={ self .image_name } )"
56+
57+ def __eq__ (self , other ):
58+ """
59+ Check if two ImageInfo objects represent the same image.
60+
61+ Only the id is used for comparison since this should be the unique identifier for an image.
62+ """
63+ if not isinstance (other , ImageInfo ):
64+ return False
65+ return self .image_id == other .image_id
66+
67+ def __dict__ (self ):
68+ """Return a dictionary representation of the image."""
69+ return {"image_id" : self .image_id , "image_name" : self .image_name }
70+
71+
4172class BaseCloud (ABC ):
4273 """Base Cloud Class."""
4374
@@ -58,7 +89,8 @@ def __init__(
5889 config_file: path to pycloudlib configuration file
5990 """
6091 self .created_instances : List [BaseInstance ] = []
61- self .created_images : List [str ] = []
92+ self .created_images : List [ImageInfo ] = []
93+ self .preserved_images : List [ImageInfo ] = [] # each dict will hold an id and name
6294
6395 self ._log = logging .getLogger ("{}.{}" .format (__name__ , self .__class__ .__name__ ))
6496 self ._check_and_set_config (config_file , required_values )
@@ -185,12 +217,13 @@ def launch(
185217 raise NotImplementedError
186218
187219 @abstractmethod
188- def snapshot (self , instance , clean = True , ** kwargs ):
220+ def snapshot (self , instance , * , clean = True , keep = False , ** kwargs ):
189221 """Snapshot an instance and generate an image from it.
190222
191223 Args:
192224 instance: Instance to snapshot
193225 clean: run instance clean method before taking snapshot
226+ keep: keep the snapshot after the cloud instance is cleaned up
194227
195228 Returns:
196229 An image id
@@ -212,11 +245,18 @@ def clean(self) -> List[Exception]:
212245 instance .delete ()
213246 except Exception as e :
214247 exceptions .append (e )
215- for image_id in self .created_images :
248+ for image_info in self .created_images :
216249 try :
217- self .delete_image (image_id )
250+ self .delete_image (image_id = image_info . image_id )
218251 except Exception as e :
219252 exceptions .append (e )
253+ for image_info in self .preserved_images :
254+ # noop - just log that we're not cleaning up these images
255+ self ._log .info (
256+ "Preserved image %s [id:%s] is NOT being cleaned up." ,
257+ image_info .image_name ,
258+ image_info .image_id ,
259+ )
220260 return exceptions
221261
222262 def list_keys (self ):
@@ -311,6 +351,18 @@ def _validate_tag(tag: str):
311351 raise InvalidTagNameError (tag = tag , rules_failed = rules_failed )
312352
313353 def _get_ssh_keys (self ) -> KeyPair :
354+ """
355+ Get the ssh key pair to use for the cloud instance.
356+
357+ If no key pair is provided in the config file, the default key pair
358+ will be used. The default key pair is the id_rsa or id_ed25519 key
359+ in the user's .ssh directory.
360+
361+ :raises PycloudlibError: if no public key path is provided and no default key is found
362+ :raises PycloudlibError: if the public key path provided in the config does not exist
363+
364+ :return: KeyPair object with the public and private key paths
365+ """
314366 user = getpass .getuser ()
315367 # check if id_rsa or id_ed25519 keys exist in the user's .ssh directory
316368 possible_default_keys = [
@@ -340,3 +392,70 @@ def _get_ssh_keys(self) -> KeyPair:
340392 private_key_path = private_key_path ,
341393 name = self .config .get ("key_name" , user ),
342394 )
395+
396+ def _store_snapshot_info (
397+ self ,
398+ snapshot_id : str ,
399+ snapshot_name : str ,
400+ keep_snapshot : bool ,
401+ ) -> ImageInfo :
402+ """
403+ Save the snapshot information for later cleanup depending on the keep_snapshot arg.
404+
405+ Will either save the snapshot information to created_images or preserved_images depending
406+ on the keep_snapshot arg. These lists are used by the BaseCloud's clean() method to
407+ cleanup the snapshots when the cloud instance is cleaned up. The snapshot information
408+ is also logged appropriately and in a consistent format.
409+
410+ :param snapshot_id: ID of the snapshot (this is used later to delete the snapshot)
411+ :param snapshot_name: Name of the snapshot (this is for user reference)
412+ :param keep_snapshot: Keep the snapshot after the cloud instance is cleaned up
413+
414+ :return: ImageInfo object with the snapshot information
415+ """
416+ image_info = ImageInfo (
417+ image_id = snapshot_id ,
418+ image_name = snapshot_name ,
419+ )
420+ if not keep_snapshot :
421+ self .created_images .append (image_info )
422+ self ._log .info (
423+ "Created temporary snapshot %s" ,
424+ image_info ,
425+ )
426+ else :
427+ self .preserved_images .append (image_info )
428+ self ._log .info (
429+ "Created permanent snapshot %s" ,
430+ image_info ,
431+ )
432+ return image_info
433+
434+ def _record_image_deletion (self , image_id : str ):
435+ """
436+ Record the deletion of an image.
437+
438+ This method should be called after an image is successfully deleted.
439+ It will remove the image from the list of created_images or preserved_images
440+ so that the cloud does not attempt to re-clean it up later. It will also log
441+ the deletion of the image.
442+
443+ :param image_id: ID of the image that was deleted
444+ """
445+ if match := [i for i in self .created_images if i .image_id == image_id ]:
446+ deleted_image = match [0 ]
447+ self .created_images .remove (deleted_image )
448+ self ._log .debug (
449+ "Snapshot %s has been deleted. Will no longer need to be cleaned up later." ,
450+ deleted_image ,
451+ )
452+ elif match := [i for i in self .preserved_images if i .image_id == image_id ]:
453+ deleted_image = match [0 ]
454+ self .preserved_images .remove (deleted_image )
455+ self ._log .debug (
456+ "Snapshot %s has been deleted. This snapshot was taken with keep=True, "
457+ "but since it has been manually deleted, it will not be preserved." ,
458+ deleted_image ,
459+ )
460+ else :
461+ self ._log .debug ("Deleted image %s" , image_id )
0 commit comments