Skip to content

Commit cd62eaf

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 4153429 commit cd62eaf

File tree

24 files changed

+315
-67
lines changed

24 files changed

+315
-67
lines changed

VERSION

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

examples/az.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import logging
66

77
import pycloudlib
8-
from pycloudlib.cloud import ImageType
8+
from pycloudlib.types import ImageType
99

1010
cloud_config = """#cloud-config
1111
runcmd:

examples/ec2.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import os
77

88
import pycloudlib
9-
from pycloudlib.cloud import ImageType
9+
from pycloudlib.types import ImageType
1010

1111

1212
def hot_add(ec2, daily):

examples/gce.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import os
77

88
import pycloudlib
9-
from pycloudlib.cloud import ImageType
9+
from pycloudlib.types import ImageType
1010

1111

1212
def manage_ssh_key(gce):

examples/lxd.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import textwrap
77

88
import pycloudlib
9-
from pycloudlib.cloud import ImageType
9+
from pycloudlib.types import ImageType
1010

1111
RELEASE = "noble"
1212

pycloudlib/azure/cloud.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515

1616
from pycloudlib.azure import security_types, util
1717
from pycloudlib.azure.instance import AzureInstance, VMInstanceStatus
18-
from pycloudlib.cloud import BaseCloud, ImageType
18+
from pycloudlib.cloud import BaseCloud
19+
from pycloudlib.types import ImageType
1920
from pycloudlib.config import ConfigFile
2021
from pycloudlib.errors import (
2122
InstanceNotFoundError,
@@ -636,7 +637,7 @@ def delete_image(self, image_id, **kwargs):
636637
if delete_poller.status() == "Succeeded":
637638
if image_id in self.registered_images:
638639
del self.registered_images[image_id]
639-
self._log.debug("Image %s was deleted", image_id)
640+
self._record_image_deletion(image_id)
640641
else:
641642
self._log.debug(
642643
"Error deleting %s. Status: %d",
@@ -1101,12 +1102,13 @@ class compatibility.
11011102

11021103
raise InstanceNotFoundError(resource_id=instance_id)
11031104

1104-
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):
11051106
"""Snapshot an instance and generate an image from it.
11061107
11071108
Args:
11081109
instance: Instance to snapshot
11091110
clean: Run instance clean method before taking snapshot
1111+
keep: keep the snapshot after the cloud instance is cleaned up
11101112
delete_provisioned_user: Deletes the last provisioned user
11111113
kwargs: Other named arguments specific to this implementation
11121114
@@ -1138,7 +1140,11 @@ def snapshot(self, instance, clean=True, delete_provisioned_user=True, **kwargs)
11381140
image_id = image.id
11391141
image_name = image.name
11401142

1141-
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+
)
11421148

11431149
self.registered_images[image_id] = {
11441150
"name": image_name,

pycloudlib/cloud.py

Lines changed: 82 additions & 15 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
@@ -20,24 +21,14 @@
2021
)
2122
from pycloudlib.instance import BaseInstance
2223
from pycloudlib.key import KeyPair
24+
from pycloudlib.types import ImageInfo, ImageType
2325
from pycloudlib.util import (
2426
get_timestamped_tag,
2527
log_exception_list,
2628
)
2729

2830
_RequiredValues = Optional[Sequence[Optional[Any]]]
2931

30-
31-
@enum.unique
32-
class ImageType(enum.Enum):
33-
"""Allowed image types when launching cloud images."""
34-
35-
GENERIC = "generic"
36-
MINIMAL = "minimal"
37-
PRO = "Pro"
38-
PRO_FIPS = "Pro FIPS"
39-
40-
4132
class BaseCloud(ABC):
4233
"""Base Cloud Class."""
4334

@@ -58,7 +49,8 @@ def __init__(
5849
config_file: path to pycloudlib configuration file
5950
"""
6051
self.created_instances: List[BaseInstance] = []
61-
self.created_images: List[str] = []
52+
self.created_images: List[ImageInfo] = []
53+
self.preserved_images: List[ImageInfo] = [] # each dict will hold an id and name
6254

6355
self._log = logging.getLogger("{}.{}".format(__name__, self.__class__.__name__))
6456
self.config = self._check_and_get_config(config_file, required_values)
@@ -189,12 +181,13 @@ def launch(
189181
raise NotImplementedError
190182

191183
@abstractmethod
192-
def snapshot(self, instance, clean=True, **kwargs):
184+
def snapshot(self, instance, *, clean=True, keep=False, **kwargs):
193185
"""Snapshot an instance and generate an image from it.
194186
195187
Args:
196188
instance: Instance to snapshot
197189
clean: run instance clean method before taking snapshot
190+
keep: keep the snapshot after the cloud instance is cleaned up
198191
199192
Returns:
200193
An image id
@@ -216,11 +209,18 @@ def clean(self) -> List[Exception]:
216209
instance.delete()
217210
except Exception as e:
218211
exceptions.append(e)
219-
for image_id in self.created_images:
212+
for image_info in self.created_images:
220213
try:
221-
self.delete_image(image_id)
214+
self.delete_image(image_id=image_info.image_id)
222215
except Exception as e:
223216
exceptions.append(e)
217+
for image_info in self.preserved_images:
218+
# noop - just log that we're not cleaning up these images
219+
self._log.info(
220+
"Preserved image %s [id:%s] is NOT being cleaned up.",
221+
image_info.image_name,
222+
image_info.image_id,
223+
)
224224
return exceptions
225225

226226
def list_keys(self):
@@ -371,3 +371,70 @@ def _get_ssh_keys(
371371
private_key_path=private_key_path,
372372
name=name,
373373
)
374+
375+
def _store_snapshot_info(
376+
self,
377+
snapshot_id: str,
378+
snapshot_name: str,
379+
keep_snapshot: bool,
380+
) -> ImageInfo:
381+
"""
382+
Save the snapshot information for later cleanup depending on the keep_snapshot arg.
383+
384+
Will either save the snapshot information to created_images or preserved_images depending
385+
on the keep_snapshot arg. These lists are used by the BaseCloud's clean() method to
386+
cleanup the snapshots when the cloud instance is cleaned up. The snapshot information
387+
is also logged appropriately and in a consistent format.
388+
389+
:param snapshot_id: ID of the snapshot (this is used later to delete the snapshot)
390+
:param snapshot_name: Name of the snapshot (this is for user reference)
391+
:param keep_snapshot: Keep the snapshot after the cloud instance is cleaned up
392+
393+
:return: ImageInfo object with 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: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66

77
import botocore
88

9-
from pycloudlib.cloud import BaseCloud, ImageType
9+
from pycloudlib.cloud import BaseCloud
10+
from pycloudlib.types import ImageType
1011
from pycloudlib.config import ConfigFile
1112
from pycloudlib.ec2.instance import EC2Instance
1213
from pycloudlib.ec2.util import _get_session, _tag_resource
@@ -294,6 +295,8 @@ def delete_image(self, image_id, **kwargs):
294295
self._log.debug("removing custom snapshot %s", snapshot_id)
295296
self.client.delete_snapshot(SnapshotId=snapshot_id)
296297

298+
self._record_image_deletion(image_id)
299+
297300
def delete_key(self, name):
298301
"""Delete an uploaded key.
299302
@@ -416,12 +419,13 @@ def list_keys(self):
416419
keypair_names.append(keypair["KeyName"])
417420
return keypair_names
418421

419-
def snapshot(self, instance, clean=True):
422+
def snapshot(self, instance, *, clean=True, keep=False, **kwargs):
420423
"""Snapshot an instance and generate an image from it.
421424
422425
Args:
423426
instance: Instance to snapshot
424427
clean: run instance clean method before taking snapshot
428+
keep: keep the snapshot after the cloud instance is cleaned up
425429
426430
Returns:
427431
An image id
@@ -440,7 +444,12 @@ def snapshot(self, instance, clean=True):
440444
)
441445
image_ami_edited = response["ImageId"]
442446
image = self.resource.Image(image_ami_edited)
443-
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+
)
444453

445454
self._wait_for_snapshot(image)
446455
_tag_resource(image, self.tag)

pycloudlib/gce/cloud.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
from google.api_core.extended_operation import ExtendedOperation
1717
from google.cloud import compute_v1
1818

19-
from pycloudlib.cloud import BaseCloud, ImageType
19+
from pycloudlib.cloud import BaseCloud
20+
from pycloudlib.types import ImageType
2021
from pycloudlib.config import ConfigFile
2122
from pycloudlib.errors import (
2223
CloudSetupError,
@@ -314,6 +315,7 @@ def delete_image(self, image_id, **kwargs):
314315
raise_on_error(operation)
315316
except GoogleAPICallError as e:
316317
raise_on_error(e)
318+
self._record_image_deletion(image_id)
317319

318320
def get_instance(
319321
self,
@@ -427,12 +429,13 @@ def launch(
427429
self.created_instances.append(instance)
428430
return instance
429431

430-
def snapshot(self, instance: GceInstance, clean=True, **kwargs):
432+
def snapshot(self, instance: GceInstance, *, clean=True, keep=False, **kwargs):
431433
"""Snapshot an instance and generate an image from it.
432434
433435
Args:
434436
instance: Instance to snapshot
435437
clean: run instance clean method before taking snapshot
438+
keep: keep the snapshot after the cloud instance is cleaned up
436439
437440
Returns:
438441
An image id
@@ -470,7 +473,11 @@ def snapshot(self, instance: GceInstance, clean=True, **kwargs):
470473
self._wait_for_operation(operation)
471474

472475
image_id = "projects/{}/global/images/{}".format(self.project, snapshot_name)
473-
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+
)
474481
return image_id
475482

476483
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]:

0 commit comments

Comments
 (0)