Skip to content

Commit 1b4a56c

Browse files
committed
feat!: add ability to retain snapshot after cleanup
This allows for marking snapshots to be kept at time of creation instead of being cleaned up automatically. This allows for still using context managers for easy cleanup while retaining snapshots where desired. This functionality can also be used when not using context managers and instead the cloud.clean() method is manually called, like in cloud-init's integration tests. BREAKING CHANGE: signature of cloud.snapshot() public method has been changed and now all args except for the first (instance) must be passed as named args.
1 parent faf2d9c commit 1b4a56c

File tree

13 files changed

+214
-42
lines changed

13 files changed

+214
-42
lines changed

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1!10.2.0
1+
1!11.0.0

pycloudlib/azure/cloud.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -627,7 +627,7 @@ def delete_image(self, image_id, **kwargs):
627627
if delete_poller.status() == "Succeeded":
628628
if image_id in self.registered_images:
629629
del self.registered_images[image_id]
630-
self._log.debug("Image %s was deleted", image_id)
630+
self._record_image_deletion(image_id)
631631
else:
632632
self._log.debug(
633633
"Error deleting %s. Status: %d",
@@ -1092,12 +1092,13 @@ class compatibility.
10921092

10931093
raise InstanceNotFoundError(resource_id=instance_id)
10941094

1095-
def snapshot(self, instance, clean=True, delete_provisioned_user=True, **kwargs):
1095+
def snapshot(self, instance, *, clean=True, keep=False, delete_provisioned_user=True, **kwargs):
10961096
"""Snapshot an instance and generate an image from it.
10971097
10981098
Args:
10991099
instance: Instance to snapshot
11001100
clean: Run instance clean method before taking snapshot
1101+
keep: keep the snapshot after the cloud instance is cleaned up
11011102
delete_provisioned_user: Deletes the last provisioned user
11021103
kwargs: Other named arguments specific to this implementation
11031104
@@ -1129,7 +1130,11 @@ def snapshot(self, instance, clean=True, delete_provisioned_user=True, **kwargs)
11291130
image_id = image.id
11301131
image_name = image.name
11311132

1132-
self.created_images.append(image_id)
1133+
self._store_snapshot_info(
1134+
snapshot_id=image_id,
1135+
snapshot_name=image_name,
1136+
keep_snapshot=keep,
1137+
)
11331138

11341139
self.registered_images[image_id] = {
11351140
"name": image_name,

pycloudlib/cloud.py

Lines changed: 123 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
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
45
import enum
56
import getpass
67
import 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+
4172
class 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)

pycloudlib/ec2/cloud.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,8 @@ def delete_image(self, image_id, **kwargs):
291291
self._log.debug("removing custom snapshot %s", snapshot_id)
292292
self.client.delete_snapshot(SnapshotId=snapshot_id)
293293

294+
self._record_image_deletion(image_id)
295+
294296
def delete_key(self, name):
295297
"""Delete an uploaded key.
296298
@@ -413,12 +415,13 @@ def list_keys(self):
413415
keypair_names.append(keypair["KeyName"])
414416
return keypair_names
415417

416-
def snapshot(self, instance, clean=True):
418+
def snapshot(self, instance, *, clean=True, keep=False, **kwargs):
417419
"""Snapshot an instance and generate an image from it.
418420
419421
Args:
420422
instance: Instance to snapshot
421423
clean: run instance clean method before taking snapshot
424+
keep: keep the snapshot after the cloud instance is cleaned up
422425
423426
Returns:
424427
An image id
@@ -437,7 +440,12 @@ def snapshot(self, instance, clean=True):
437440
)
438441
image_ami_edited = response["ImageId"]
439442
image = self.resource.Image(image_ami_edited)
440-
self.created_images.append(image.id)
443+
444+
self._store_snapshot_info(
445+
snapshot_id=image.id,
446+
snapshot_name=image.name,
447+
keep_snapshot=keep,
448+
)
441449

442450
self._wait_for_snapshot(image)
443451
_tag_resource(image, self.tag)

pycloudlib/gce/cloud.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,7 @@ def delete_image(self, image_id, **kwargs):
314314
raise_on_error(operation)
315315
except GoogleAPICallError as e:
316316
raise_on_error(e)
317+
self._record_image_deletion(image_id)
317318

318319
def get_instance(
319320
self,
@@ -427,12 +428,13 @@ def launch(
427428
self.created_instances.append(instance)
428429
return instance
429430

430-
def snapshot(self, instance: GceInstance, clean=True, **kwargs):
431+
def snapshot(self, instance: GceInstance, *, clean=True, keep=False, **kwargs):
431432
"""Snapshot an instance and generate an image from it.
432433
433434
Args:
434435
instance: Instance to snapshot
435436
clean: run instance clean method before taking snapshot
437+
keep: keep the snapshot after the cloud instance is cleaned up
436438
437439
Returns:
438440
An image id
@@ -470,7 +472,11 @@ def snapshot(self, instance: GceInstance, clean=True, **kwargs):
470472
self._wait_for_operation(operation)
471473

472474
image_id = "projects/{}/global/images/{}".format(self.project, snapshot_name)
473-
self.created_images.append(image_id)
475+
self._store_snapshot_info(
476+
snapshot_name=snapshot_name,
477+
snapshot_id=image_id,
478+
keep_snapshot=keep,
479+
)
474480
return image_id
475481

476482
def _wait_for_operation(self, operation, operation_type="global", sleep_seconds=300):

pycloudlib/ibm/cloud.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
from pycloudlib.cloud import BaseCloud
1515
from pycloudlib.config import ConfigFile
16-
from pycloudlib.errors import InvalidTagNameError
16+
from pycloudlib.errors import InvalidTagNameError, ResourceNotFoundError, ResourceType
1717
from pycloudlib.ibm._util import get_first as _get_first
1818
from pycloudlib.ibm._util import iter_resources as _iter_resources
1919
from pycloudlib.ibm._util import wait_until as _wait_until
@@ -130,7 +130,9 @@ def delete_image(self, image_id: str, **kwargs):
130130
self._client.delete_image(image_id).get_result()
131131
except ApiException as e:
132132
if "does not exist" not in str(e):
133-
raise
133+
raise ResourceNotFoundError(ResourceType.IMAGE, image_id) from e
134+
else:
135+
self._record_image_deletion(image_id)
134136

135137
def released_image(self, release, *, arch: str = "amd64", **kwargs):
136138
"""ID of the latest released image for a particular release.
@@ -312,12 +314,13 @@ def launch(
312314

313315
return instance
314316

315-
def snapshot(self, instance: IBMInstance, clean: bool = True, **kwargs) -> str:
317+
def snapshot(self, instance: IBMInstance, *, clean=True, keep=False, **kwargs) -> str:
316318
"""Snapshot an instance and generate an image from it.
317319
318320
Args:
319321
instance: Instance to snapshot
320322
clean: run instance clean method before taking snapshot
323+
keep: keep the snapshot after the cloud instance is cleaned up
321324
322325
Returns:
323326
An image id
@@ -347,7 +350,11 @@ def snapshot(self, instance: IBMInstance, clean: bool = True, **kwargs) -> str:
347350
f"Snapshot not available after {timeout_seconds} seconds. Check IBM VPC console."
348351
),
349352
)
350-
self.created_images.append(snapshot_id)
353+
self._store_snapshot_info(
354+
snapshot_name=str(image_prototype["name"]),
355+
snapshot_id=snapshot_id,
356+
keep_snapshot=keep,
357+
)
351358
return snapshot_id
352359

353360
def list_keys(self) -> List[str]:

pycloudlib/ibm_classic/cloud.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ def delete_image(self, image_id: str, **kwargs):
8181
) from e
8282
except SoftLayer.SoftLayerAPIError as e:
8383
raise IBMClassicException(f"Error deleting image {image_id}") from e
84+
else:
85+
self._record_image_deletion(image_id)
8486

8587
def released_image(self, release, *, disk_size: str = "25G", **kwargs):
8688
"""ID (globalIdentifier) of the latest released image for a particular release.
@@ -267,7 +269,9 @@ def launch(
267269
def snapshot(
268270
self,
269271
instance,
272+
*,
270273
clean=True,
274+
keep=False,
271275
note: Optional[str] = None,
272276
**kwargs,
273277
):
@@ -276,6 +280,7 @@ def snapshot(
276280
Args:
277281
instance: Instance to snapshot
278282
clean: run instance clean method before taking snapshot
283+
keep: keep the snapshot after the cloud instance is cleaned up
279284
note: optional note to add to the snapshot
280285
281286
Returns:
@@ -290,10 +295,10 @@ def snapshot(
290295
name=f"{self.tag}-snapshot",
291296
notes=note,
292297
)
293-
self._log.info(
294-
"Successfully created snapshot '%s' with ID: %s",
295-
snapshot_result["name"],
296-
snapshot_result["id"],
298+
self._store_snapshot_info(
299+
snapshot_name=snapshot_result["name"],
300+
snapshot_id=snapshot_result["id"],
301+
keep_snapshot=keep,
297302
)
298303
return snapshot_result["id"]
299304

0 commit comments

Comments
 (0)