Skip to content

Commit 12bb625

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 a6a603b commit 12bb625

File tree

13 files changed

+282
-41
lines changed

13 files changed

+282
-41
lines changed

pycloudlib/azure/cloud.py

+8-3
Original file line numberDiff line numberDiff line change
@@ -637,7 +637,7 @@ def delete_image(self, image_id, **kwargs):
637637
if delete_poller.status() == "Succeeded":
638638
if image_id in self.registered_images:
639639
del self.registered_images[image_id]
640-
self._log.debug("Image %s was deleted", image_id)
640+
self._record_image_deletion(image_id)
641641
else:
642642
self._log.debug(
643643
"Error deleting %s. Status: %d",
@@ -1102,12 +1102,13 @@ class compatibility.
11021102

11031103
raise InstanceNotFoundError(resource_id=instance_id)
11041104

1105-
def snapshot(self, instance, clean=True, delete_provisioned_user=True, **kwargs):
1105+
def snapshot(self, instance, *, clean=True, keep=False, delete_provisioned_user=True, **kwargs):
11061106
"""Snapshot an instance and generate an image from it.
11071107
11081108
Args:
11091109
instance: Instance to snapshot
11101110
clean: Run instance clean method before taking snapshot
1111+
keep: keep the snapshot after the cloud instance is cleaned up
11111112
delete_provisioned_user: Deletes the last provisioned user
11121113
kwargs: Other named arguments specific to this implementation
11131114
@@ -1139,7 +1140,11 @@ def snapshot(self, instance, clean=True, delete_provisioned_user=True, **kwargs)
11391140
image_id = image.id
11401141
image_name = image.name
11411142

1142-
self.created_images.append(image_id)
1143+
self._store_snapshot_info(
1144+
snapshot_id=image_id,
1145+
snapshot_name=image_name,
1146+
keep_snapshot=keep,
1147+
)
11431148

11441149
self.registered_images[image_id] = {
11451150
"name": image_name,

pycloudlib/cloud.py

+81-4
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,70 @@ 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 arg.
381+
382+
Will either save the snapshot information to created_images or preserved_images depending
383+
on the keep_snapshot arg. These lists are used by the BaseCloud's clean() method to
384+
cleanup the snapshots when the cloud instance is cleaned up. The snapshot information
385+
is also logged appropriately and in a consistent format.
386+
387+
:param snapshot_id: ID of the snapshot (this is used later to delete the snapshot)
388+
:param snapshot_name: Name of the snapshot (this is for user reference)
389+
:param keep_snapshot: Keep the snapshot after the cloud instance is cleaned up
390+
391+
:return: ImageInfo object with the snapshot information
392+
"""
393+
image_info = ImageInfo(
394+
image_id=snapshot_id,
395+
image_name=snapshot_name,
396+
)
397+
if not keep_snapshot:
398+
self.created_images.append(image_info)
399+
self._log.info(
400+
"Created temporary snapshot %s",
401+
image_info,
402+
)
403+
else:
404+
self.preserved_images.append(image_info)
405+
self._log.info(
406+
"Created permanent snapshot %s",
407+
image_info,
408+
)
409+
return image_info
410+
411+
def _record_image_deletion(self, image_id: str):
412+
"""
413+
Record the deletion of an image.
414+
415+
This method should be called after an image is successfully deleted.
416+
It will remove the image from the list of created_images or preserved_images
417+
so that the cloud does not attempt to re-clean it up later. It will also log
418+
the deletion of the image.
419+
420+
:param image_id: ID of the image that was deleted
421+
"""
422+
if match := [i for i in self.created_images if i.image_id == image_id]:
423+
deleted_image = match[0]
424+
self.created_images.remove(deleted_image)
425+
self._log.debug(
426+
"Snapshot %s has been deleted. Will no longer need to be cleaned up later.",
427+
deleted_image,
428+
)
429+
elif match := [i for i in self.preserved_images if i.image_id == image_id]:
430+
deleted_image = match[0]
431+
self.preserved_images.remove(deleted_image)
432+
self._log.debug(
433+
"Snapshot %s has been deleted. This snapshot was taken with keep=True, "
434+
"but since it has been manually deleted, it will not be preserved.",
435+
deleted_image,
436+
)
437+
else:
438+
self._log.debug("Deleted image %s", image_id)

pycloudlib/ec2/cloud.py

+10-2
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,8 @@ def delete_image(self, image_id, **kwargs):
295295
self._log.debug("removing custom snapshot %s", snapshot_id)
296296
self.client.delete_snapshot(SnapshotId=snapshot_id)
297297

298+
self._record_image_deletion(image_id)
299+
298300
def delete_key(self, name):
299301
"""Delete an uploaded key.
300302
@@ -417,12 +419,13 @@ def list_keys(self):
417419
keypair_names.append(keypair["KeyName"])
418420
return keypair_names
419421

420-
def snapshot(self, instance, clean=True):
422+
def snapshot(self, instance, *, clean=True, keep=False, **kwargs):
421423
"""Snapshot an instance and generate an image from it.
422424
423425
Args:
424426
instance: Instance to snapshot
425427
clean: run instance clean method before taking snapshot
428+
keep: keep the snapshot after the cloud instance is cleaned up
426429
427430
Returns:
428431
An image id
@@ -441,7 +444,12 @@ def snapshot(self, instance, clean=True):
441444
)
442445
image_ami_edited = response["ImageId"]
443446
image = self.resource.Image(image_ami_edited)
444-
self.created_images.append(image.id)
447+
448+
self._store_snapshot_info(
449+
snapshot_id=image.id,
450+
snapshot_name=image.name,
451+
keep_snapshot=keep,
452+
)
445453

446454
self._wait_for_snapshot(image)
447455
_tag_resource(image, self.tag)

pycloudlib/gce/cloud.py

+8-2
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,7 @@ def delete_image(self, image_id, **kwargs):
315315
raise_on_error(operation)
316316
except GoogleAPICallError as e:
317317
raise_on_error(e)
318+
self._record_image_deletion(image_id)
318319

319320
def get_instance(
320321
self,
@@ -428,12 +429,13 @@ def launch(
428429
self.created_instances.append(instance)
429430
return instance
430431

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

473475
image_id = "projects/{}/global/images/{}".format(self.project, snapshot_name)
474-
self.created_images.append(image_id)
476+
self._store_snapshot_info(
477+
snapshot_name=snapshot_name,
478+
snapshot_id=image_id,
479+
keep_snapshot=keep,
480+
)
475481
return image_id
476482

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

pycloudlib/ibm/cloud.py

+11-4
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

+8-4
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

+7-11
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)