Skip to content

Commit 01ab537

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 cef8a8c commit 01ab537

File tree

13 files changed

+289
-41
lines changed

13 files changed

+289
-41
lines changed

pycloudlib/azure/cloud.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -643,7 +643,7 @@ def delete_image(self, image_id, **kwargs):
643643
if delete_poller.status() == "Succeeded":
644644
if image_id in self.registered_images:
645645
del self.registered_images[image_id]
646-
self._log.debug("Image %s was deleted", image_id)
646+
self._record_image_deletion(image_id)
647647
else:
648648
self._log.debug(
649649
"Error deleting %s. Status: %d",
@@ -1110,12 +1110,13 @@ class compatibility.
11101110

11111111
raise InstanceNotFoundError(resource_id=instance_id)
11121112

1113-
def snapshot(self, instance, clean=True, delete_provisioned_user=True, **kwargs):
1113+
def snapshot(self, instance, *, clean=True, keep=False, delete_provisioned_user=True, **kwargs):
11141114
"""Snapshot an instance and generate an image from it.
11151115
11161116
Args:
11171117
instance: Instance to snapshot
11181118
clean: Run instance clean method before taking snapshot
1119+
keep: keep the snapshot after the cloud instance is cleaned up
11191120
delete_provisioned_user: Deletes the last provisioned user
11201121
kwargs: Other named arguments specific to this implementation
11211122
@@ -1147,7 +1148,11 @@ def snapshot(self, instance, clean=True, delete_provisioned_user=True, **kwargs)
11471148
image_id = image.id
11481149
image_name = image.name
11491150

1150-
self.created_images.append(image_id)
1151+
self._store_snapshot_info(
1152+
snapshot_id=image_id,
1153+
snapshot_name=image_name,
1154+
keep_snapshot=keep,
1155+
)
11511156

11521157
self.registered_images[image_id] = {
11531158
"name": image_name,

pycloudlib/cloud.py

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
)
2020
from pycloudlib.instance import BaseInstance
2121
from pycloudlib.key import KeyPair
22+
from pycloudlib.types import ImageInfo
2223
from pycloudlib.util import (
2324
get_timestamped_tag,
2425
log_exception_list,
@@ -47,7 +48,8 @@ def __init__(
4748
config_file: path to pycloudlib configuration file
4849
"""
4950
self.created_instances: List[BaseInstance] = []
50-
self.created_images: List[str] = []
51+
self.created_images: List[ImageInfo] = []
52+
self.preserved_images: List[ImageInfo] = [] # each dict will hold an id and name
5153

5254
self._log = logging.getLogger("{}.{}".format(__name__, self.__class__.__name__))
5355
self.config = self._check_and_get_config(config_file, required_values)
@@ -177,12 +179,13 @@ def launch(
177179
raise NotImplementedError
178180

179181
@abstractmethod
180-
def snapshot(self, instance, clean=True, **kwargs):
182+
def snapshot(self, instance, *, clean=True, keep=False, **kwargs):
181183
"""Snapshot an instance and generate an image from it.
182184
183185
Args:
184186
instance: Instance to snapshot
185187
clean: run instance clean method before taking snapshot
188+
keep: keep the snapshot after the cloud instance is cleaned up
186189
187190
Returns:
188191
An image id
@@ -204,11 +207,18 @@ def clean(self) -> List[Exception]:
204207
instance.delete()
205208
except Exception as e:
206209
exceptions.append(e)
207-
for image_id in self.created_images:
210+
for image_info in self.created_images:
208211
try:
209-
self.delete_image(image_id)
212+
self.delete_image(image_id=image_info.image_id)
210213
except Exception as e:
211214
exceptions.append(e)
215+
for image_info in self.preserved_images:
216+
# noop - just log that we're not cleaning up these images
217+
self._log.info(
218+
"Preserved image %s [id:%s] is NOT being cleaned up.",
219+
image_info.image_name,
220+
image_info.image_id,
221+
)
212222
return exceptions
213223

214224
def list_keys(self):
@@ -359,3 +369,72 @@ def _get_ssh_keys(
359369
private_key_path=private_key_path,
360370
name=name,
361371
)
372+
373+
def _store_snapshot_info(
374+
self,
375+
snapshot_id: str,
376+
snapshot_name: str,
377+
keep_snapshot: bool,
378+
) -> ImageInfo:
379+
"""
380+
Save the snapshot information for later cleanup depending on the keep_snapshot argument.
381+
382+
This method saves the snapshot information to either `created_images` or `preserved_images`
383+
based on the value of `keep_snapshot`. These lists are used by the `BaseCloud`'s `clean()`
384+
method to manage snapshots during cleanup. The snapshot information is also logged in a
385+
consistent format so that individual clouds do NOT need to worry about logging.
386+
387+
Args:
388+
snapshot_id (str): ID of the snapshot (used later to delete the snapshot).
389+
snapshot_name (str): Name of the snapshot (for user reference).
390+
keep_snapshot (bool): Whether to keep the snapshot after the cloud instance is cleaned up.
391+
392+
Returns:
393+
ImageInfo: An ImageInfo object containing the snapshot information.
394+
"""
395+
image_info = ImageInfo(
396+
image_id=snapshot_id,
397+
image_name=snapshot_name,
398+
)
399+
if not keep_snapshot:
400+
self.created_images.append(image_info)
401+
self._log.info(
402+
"Created temporary snapshot %s",
403+
image_info,
404+
)
405+
else:
406+
self.preserved_images.append(image_info)
407+
self._log.info(
408+
"Created permanent snapshot %s",
409+
image_info,
410+
)
411+
return image_info
412+
413+
def _record_image_deletion(self, image_id: str):
414+
"""
415+
Record the deletion of an image.
416+
417+
This method should be called after an image is successfully deleted.
418+
It will remove the image from the list of created_images or preserved_images
419+
so that the cloud does not attempt to re-clean it up later. It will also log
420+
the deletion of the image.
421+
422+
:param image_id: ID of the image that was deleted
423+
"""
424+
if match := [i for i in self.created_images if i.image_id == image_id]:
425+
deleted_image = match[0]
426+
self.created_images.remove(deleted_image)
427+
self._log.debug(
428+
"Snapshot %s has been deleted. Will no longer need to be cleaned up later.",
429+
deleted_image,
430+
)
431+
elif match := [i for i in self.preserved_images if i.image_id == image_id]:
432+
deleted_image = match[0]
433+
self.preserved_images.remove(deleted_image)
434+
self._log.debug(
435+
"Snapshot %s has been deleted. This snapshot was taken with keep=True, "
436+
"but since it has been manually deleted, it will not be preserved.",
437+
deleted_image,
438+
)
439+
else:
440+
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
@@ -298,6 +298,8 @@ def delete_image(self, image_id, **kwargs):
298298
self._log.debug("removing custom snapshot %s", snapshot_id)
299299
self.client.delete_snapshot(SnapshotId=snapshot_id)
300300

301+
self._record_image_deletion(image_id)
302+
301303
def delete_key(self, name):
302304
"""Delete an uploaded key.
303305
@@ -420,12 +422,13 @@ def list_keys(self):
420422
keypair_names.append(keypair["KeyName"])
421423
return keypair_names
422424

423-
def snapshot(self, instance, clean=True):
425+
def snapshot(self, instance, *, clean=True, keep=False, **kwargs):
424426
"""Snapshot an instance and generate an image from it.
425427
426428
Args:
427429
instance: Instance to snapshot
428430
clean: run instance clean method before taking snapshot
431+
keep: keep the snapshot after the cloud instance is cleaned up
429432
430433
Returns:
431434
An image id
@@ -444,7 +447,12 @@ def snapshot(self, instance, clean=True):
444447
)
445448
image_ami_edited = response["ImageId"]
446449
image = self.resource.Image(image_ami_edited)
447-
self.created_images.append(image.id)
450+
451+
self._store_snapshot_info(
452+
snapshot_id=image.id,
453+
snapshot_name=image.name,
454+
keep_snapshot=keep,
455+
)
448456

449457
self._wait_for_snapshot(image)
450458
_tag_resource(image, self.tag)

pycloudlib/gce/cloud.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,7 @@ def delete_image(self, image_id, **kwargs):
320320
raise_on_error(operation)
321321
except GoogleAPICallError as e:
322322
raise_on_error(e)
323+
self._record_image_deletion(image_id)
323324

324325
def get_instance(
325326
self,
@@ -433,12 +434,13 @@ def launch(
433434
self.created_instances.append(instance)
434435
return instance
435436

436-
def snapshot(self, instance: GceInstance, clean=True, **kwargs):
437+
def snapshot(self, instance: GceInstance, *, clean=True, keep=False, **kwargs):
437438
"""Snapshot an instance and generate an image from it.
438439
439440
Args:
440441
instance: Instance to snapshot
441442
clean: run instance clean method before taking snapshot
443+
keep: keep the snapshot after the cloud instance is cleaned up
442444
443445
Returns:
444446
An image id
@@ -476,7 +478,11 @@ def snapshot(self, instance: GceInstance, clean=True, **kwargs):
476478
self._wait_for_operation(operation)
477479

478480
image_id = "projects/{}/global/images/{}".format(self.project, snapshot_name)
479-
self.created_images.append(image_id)
481+
self._store_snapshot_info(
482+
snapshot_name=snapshot_name,
483+
snapshot_id=image_id,
484+
keep_snapshot=keep,
485+
)
480486
return image_id
481487

482488
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
@@ -134,7 +134,9 @@ def delete_image(self, image_id: str, **kwargs):
134134
self._client.delete_image(image_id).get_result()
135135
except ApiException as e:
136136
if "does not exist" not in str(e):
137-
raise
137+
raise ResourceNotFoundError(ResourceType.IMAGE, image_id) from e
138+
else:
139+
self._record_image_deletion(image_id)
138140

139141
def released_image(self, release, *, arch: str = "amd64", **kwargs):
140142
"""ID of the latest released image for a particular release.
@@ -316,12 +318,13 @@ def launch(
316318

317319
return instance
318320

319-
def snapshot(self, instance: IBMInstance, clean: bool = True, **kwargs) -> str:
321+
def snapshot(self, instance: IBMInstance, *, clean=True, keep=False, **kwargs) -> str:
320322
"""Snapshot an instance and generate an image from it.
321323
322324
Args:
323325
instance: Instance to snapshot
324326
clean: run instance clean method before taking snapshot
327+
keep: keep the snapshot after the cloud instance is cleaned up
325328
326329
Returns:
327330
An image id
@@ -351,7 +354,11 @@ def snapshot(self, instance: IBMInstance, clean: bool = True, **kwargs) -> str:
351354
f"Snapshot not available after {timeout_seconds} seconds. Check IBM VPC console."
352355
),
353356
)
354-
self.created_images.append(snapshot_id)
357+
self._store_snapshot_info(
358+
snapshot_name=str(image_prototype["name"]),
359+
snapshot_id=snapshot_id,
360+
keep_snapshot=keep,
361+
)
355362
return snapshot_id
356363

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

pycloudlib/ibm_classic/cloud.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ 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+
self._record_image_deletion(image_id)
8485

8586
def released_image(self, release, *, disk_size: str = "25G", **kwargs):
8687
"""ID (globalIdentifier) of the latest released image for a particular release.
@@ -267,7 +268,9 @@ def launch(
267268
def snapshot(
268269
self,
269270
instance,
271+
*,
270272
clean=True,
273+
keep=False,
271274
note: Optional[str] = None,
272275
**kwargs,
273276
):
@@ -276,6 +279,7 @@ def snapshot(
276279
Args:
277280
instance: Instance to snapshot
278281
clean: run instance clean method before taking snapshot
282+
keep: keep the snapshot after the cloud instance is cleaned up
279283
note: optional note to add to the snapshot
280284
281285
Returns:
@@ -290,10 +294,10 @@ def snapshot(
290294
name=f"{self.tag}-snapshot",
291295
notes=note,
292296
)
293-
self._log.info(
294-
"Successfully created snapshot '%s' with ID: %s",
295-
snapshot_result["name"],
296-
snapshot_result["id"],
297+
self._store_snapshot_info(
298+
snapshot_name=snapshot_result["name"],
299+
snapshot_id=snapshot_result["id"],
300+
keep_snapshot=keep,
297301
)
298302
return snapshot_result["id"]
299303

pycloudlib/lxd/cloud.py

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -394,11 +394,10 @@ def delete_image(self, image_id, **kwargs):
394394
image_id: string, LXD image fingerprint
395395
"""
396396
self._log.debug("Deleting image: '%s'", image_id)
397-
398397
subp(["lxc", "image", "delete", image_id])
399-
self._log.debug("Deleted %s", image_id)
398+
self._record_image_deletion(image_id)
400399

401-
def snapshot(self, instance, clean=True, name=None):
400+
def snapshot(self, instance: LXDInstance, *, clean=True, keep=False, name=None): # type: ignore
402401
"""Take a snapshot of the passed in instance for use as image.
403402
404403
:param instance: The instance to create an image from
@@ -413,7 +412,11 @@ def snapshot(self, instance, clean=True, name=None):
413412
instance.clean()
414413

415414
snapshot_name = instance.snapshot(name)
416-
self.created_snapshots.append(snapshot_name)
415+
self._store_snapshot_info(
416+
snapshot_name=snapshot_name,
417+
snapshot_id=snapshot_name,
418+
keep_snapshot=keep,
419+
)
417420
return snapshot_name
418421

419422
# pylint: disable=broad-except
@@ -425,13 +428,6 @@ def clean(self) -> List[Exception]:
425428
"""
426429
exceptions = super().clean()
427430

428-
for snapshot in self.created_snapshots:
429-
try:
430-
subp(["lxc", "image", "delete", snapshot])
431-
except RuntimeError as e:
432-
if "Image not found" not in str(e):
433-
exceptions.append(e)
434-
435431
for profile in self.created_profiles:
436432
try:
437433
subp(["lxc", "profile", "delete", profile])

0 commit comments

Comments
 (0)