diff --git a/doc/source/storage-pools.rst b/doc/source/storage-pools.rst index 02f61b8c..79b3ef1d 100644 --- a/doc/source/storage-pools.rst +++ b/doc/source/storage-pools.rst @@ -13,18 +13,22 @@ Storage Pool objects object that is returned from `GET /1.0/storage-pools/` and then the associated methods that are then available at the same endpoint. -There are also :py:class:`~pylxd.models.storage_pool.StorageResource` and -:py:class:`~pylxd.models.storage_pool.StorageVolume` objects that represent the -storage resources endpoint for a pool at `GET -/1.0/storage-pools//resources` and a storage volume on a pool at `GET -/1.0/storage-pools//volumes//`. Note that these should be -accessed from the storage pool object. For example: +There are also :py:class:`~pylxd.models.storage_pool.StorageResource`, +:py:class:`~pylxd.models.storage_pool.StorageVolume` and +:py:class:`~pylxd.models.storage_pool.StorageVolumeSnapshot` objects that +represent, respectively, the storage resources endpoint for a pool at +`GET /1.0/storage-pools//resources`, a storage volume on a pool at +`GET /1.0/storage-pools//volumes//` and a custom volume snapshot +at `GET /1.0/storage-pools//volumes///snapshots/`. +Note that these should be accessed from the storage pool object. For example: .. code:: python client = pylxd.Client() storage_pool = client.storage_pools.get('poolname') + resources = storage_pool.resources.get() storage_volume = storage_pool.volumes.get('custom', 'volumename') + snapshot = storage_volume.snapshots.get('snap0') .. note:: For more details of the LXD documentation concerning storage pools @@ -137,10 +141,47 @@ following methods are available: changes to the LXD server. - `delete` - delete a storage volume object. Note that the object is, therefore, stale after this action. + - `restore_from` - Restore the volume from a snapshot using the snapshot name. .. note:: `raw_put` and `raw_patch` are availble (but not documented) to allow putting and patching without syncing the object back. +Storage Volume Snapshots +------------------------ + +Storage Volume Snapshots are represented as `StorageVolumeSnapshot` objects and +stored in `StorageVolume` objects and represent snapshots of custom storage volumes. +On the `pylxd` API they are accessed from a storage volume object that, in turn, +is accessed from a storage pool object: + +.. code:: Python + + storage_pool = client.storage_pools.get('pool1') + volumes = storage_pool.volumes.all() + custom_volume = storage_pool.volumes.get('custom', 'vol1') + a_snapshot = custom_volume.snapshots.get('snap0') + +Methods available on `.snapshots` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The following methods are accessed from the `snapshots` attribute on the `StorageVolume` object: + + - `all` - Get all the snapshots from the storage volume. + - `get` - Get a single snapshot using its name. + - `create` - Take a snapshot on the current stage of the storage volume. The new snapshot's + name and expiration date can be set, default name is in the format "snapX". + - `exists` - Returns True if a storage volume snapshot with the given name exists, returns False otherwise. + +Methods available on the storage snapshot object +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Once in possession of a `StorageVolumeSnapshot` object from the `pylxd` API via `volume.snapshots.get()`, +the following methods are available: + + - `restore` - Restore the volume from the snapshot. + - `delete` - Delete the snapshot. Note that the object is, therefore, stale after this action. + - `rename` - Renames the snapshot. The endpoints that reference this snapshot will change accordingly. + .. links .. _LXD Storage Pools: https://documentation.ubuntu.com/lxd/en/latest/storage/ diff --git a/integration/test_storage.py b/integration/test_storage.py index 18078511..a1b498df 100644 --- a/integration/test_storage.py +++ b/integration/test_storage.py @@ -12,8 +12,6 @@ # License for the specific language governing permissions and limitations # under the License. -import random -import string import unittest import pylxd.exceptions as exceptions @@ -25,26 +23,7 @@ def setUp(self): super().setUp() if not self.client.has_api_extension("storage"): - self.skipTest("Required LXD API extension not available!") - - def create_storage_pool(self): - # create a storage pool in the form of 'xxx1' as a dir. - name = "".join(random.sample(string.ascii_lowercase, 3)) + "1" - self.lxd.storage_pools.post( - json={ - "config": {}, - "driver": "dir", - "name": name, - } - ) - return name - - def delete_storage_pool(self, name): - # delete the named storage pool - try: - self.lxd.storage_pools[name].delete() - except exceptions.NotFound: - pass + self.skipTest("Required 'storage' LXD API extension not available!") class TestStoragePools(StorageTestCase): @@ -126,36 +105,12 @@ def test_get(self): class TestStorageVolume(StorageTestCase): """Tests for :py:class:`pylxd.models.storage_pools.StorageVolume""" - # note create and delete are tested in every method - - def create_storage_volume(self, pool): - # note 'pool' needs to be storage_pool object or a string - if isinstance(pool, str): - pool = self.client.storage_pools.get(pool) - vol_input = { - "config": {}, - "type": "custom", - # "pool": name, - "name": "vol1", - } - volume = pool.volumes.create(vol_input) - return volume - - def delete_storage_volume(self, pool, volume): - # pool is either string or storage_pool object - # volume is either a string of storage_pool object - if isinstance(volume, str): - if isinstance(pool, str): - pool = self.client.storage_pools.get(pool) - volume = pool.volumes.get("custom", volume) - volume.delete() - def test_create_and_get_and_delete(self): pool_name = self.create_storage_pool() self.addCleanup(self.delete_storage_pool, pool_name) - storage_pool = self.client.storage_pools.get(pool_name) - volume = self.create_storage_volume(storage_pool) + + volume = self.create_storage_volume(pool_name, "vol1") vol_copy = storage_pool.volumes.get("custom", "vol1") self.assertEqual(vol_copy.name, volume.name) volume.delete() @@ -173,3 +128,80 @@ def test_patch(self): # as we're not using ZFS (and can't in these integration tests) we # can't really patch anything on a dir volume. pass + + +class TestStorageVolumeSnapshot(StorageTestCase): + """Tests for :py:class:`pylxd.models.storage_pool.StorageVolumeSnapshot""" + + def setUp(self): + super().setUp() + + if not self.client.has_api_extension("storage_api_volume_snapshots"): + self.skipTest( + "Required 'storage_api_volume_snapshots' LXD API extension not available!" + ) + + def test_create_get_restore_delete_volume_snapshot(self): + # Create pool and volume + pool = self.create_storage_pool() + self.addCleanup(self.delete_storage_pool, pool) + + volume = self.create_storage_volume(pool, "vol1") + self.addCleanup(self.delete_storage_volume, pool, "vol1") + + # Create a few snapshots + first_snapshot = volume.snapshots.create() + self.assertEqual(first_snapshot.name, "snap0") + + second_snapshot = volume.snapshots.create() + self.assertEqual(second_snapshot.name, "snap1") + + # Try restoring the volume from one of the snapshots + first_snapshot.restore() + + # Create new snapshot with defined name and expiration date + custom_snapshot_name = "custom-snapshot" + custom_snapshot_expiry_date = "2183-06-16T00:00:00Z" + + custom_snapshot = volume.snapshots.create( + name=custom_snapshot_name, expires_at=custom_snapshot_expiry_date + ) + self.assertEqual(custom_snapshot.name, custom_snapshot_name) + self.assertEqual(custom_snapshot.expires_at, custom_snapshot_expiry_date) + + # Get all snapshots from the volume + all_snapshots = volume.snapshots.all() + self.assertEqual(len(all_snapshots), 3) + + for snapshot_name in ["snap0", "snap1", custom_snapshot_name]: + self.assertIn(snapshot_name, all_snapshots) + + # Delete a snapshot + second_snapshot.delete() + + self.assertFalse(volume.snapshots.exists(second_snapshot.name)) + + self.assertRaises(exceptions.NotFound, volume.snapshots.get, "snap1") + + all_snapshots = volume.snapshots.all() + self.assertEqual(len(all_snapshots), 2) + + for snapshot_name in ["snap0", custom_snapshot_name]: + self.assertIn(snapshot_name, all_snapshots) + + self.assertFalse("snap1" in all_snapshots) + + # Test getting all snapshots with recursion + all_snapshots = volume.snapshots.all(use_recursion=True) + self.assertIn(first_snapshot, all_snapshots) + self.assertIn(custom_snapshot, all_snapshots) + + # Change snapshot values + first_snapshot.rename("first") + self.assertFalse(volume.snapshots.exists("snap0")) + self.assertRaises(exceptions.NotFound, volume.snapshots.get, "snap0") + + new_description = "first snapshot" + first_snapshot.description = new_description + first_snapshot.save(wait=True) + self.assertEqual(volume.snapshots.get("first").description, new_description) diff --git a/integration/testing.py b/integration/testing.py index 118d71e9..c7c913a1 100644 --- a/integration/testing.py +++ b/integration/testing.py @@ -157,6 +157,42 @@ def delete_network(self, name): except exceptions.NotFound: pass + def create_storage_pool(self): + # create a storage pool in the form of 'xxx1' as a dir. + name = "".join(random.sample(string.ascii_lowercase, 3)) + "1" + self.lxd.storage_pools.post( + json={ + "config": {}, + "driver": "dir", + "name": name, + } + ) + return name + + def delete_storage_pool(self, name): + # delete the named storage pool + try: + self.lxd.storage_pools[name].delete() + except exceptions.NotFound: + pass + + def create_storage_volume(self, pool_name, volume_name): + pool = self.client.storage_pools.get(pool_name) + vol_json = { + "config": {}, + "type": "custom", + "name": volume_name, + } + return pool.volumes.create(vol_json) + + def delete_storage_volume(self, pool_name, volume_name): + try: + pool = self.client.storage_pools.get(pool_name) + pool.volumes.get("custom", volume_name).delete() + return True + except exceptions.NotFound: + return False + def assertCommon(self, response): """Assert common LXD responses. diff --git a/pylxd/managers.py b/pylxd/managers.py index 2c0d6d75..9f1ec0e4 100644 --- a/pylxd/managers.py +++ b/pylxd/managers.py @@ -73,6 +73,18 @@ class StoragePoolManager(BaseManager): manager_for = "pylxd.models.StoragePool" +class StorageResourcesManager(BaseManager): + manager_for = "pylxd.models.StorageResources" + + +class StorageVolumeManager(BaseManager): + manager_for = "pylxd.models.StorageVolume" + + +class StorageVolumeSnapshotManager(BaseManager): + manager_for = "pylxd.models.StorageVolumeSnapshot" + + class ClusterMemberManager(BaseManager): manager_for = "pylxd.models.ClusterMember" diff --git a/pylxd/models/__init__.py b/pylxd/models/__init__.py index d73b59f2..45742efb 100644 --- a/pylxd/models/__init__.py +++ b/pylxd/models/__init__.py @@ -7,7 +7,12 @@ from pylxd.models.operation import Operation from pylxd.models.profile import Profile from pylxd.models.project import Project -from pylxd.models.storage_pool import StoragePool, StorageResources, StorageVolume +from pylxd.models.storage_pool import ( + StoragePool, + StorageResources, + StorageVolume, + StorageVolumeSnapshot, +) from pylxd.models.virtual_machine import VirtualMachine __all__ = [ @@ -27,5 +32,6 @@ "StoragePool", "StorageResources", "StorageVolume", + "StorageVolumeSnapshot", "VirtualMachine", ] diff --git a/pylxd/models/_model.py b/pylxd/models/_model.py index 58037ece..833d8c0e 100644 --- a/pylxd/models/_model.py +++ b/pylxd/models/_model.py @@ -173,6 +173,21 @@ def __iter__(self): for attr in self.__attributes__.keys(): yield attr, getattr(self, attr) + def __eq__(self, other): + if other.__class__ != self.__class__: + return False + + for attr in self.__attributes__.keys(): + if not hasattr(self, attr) and not hasattr(other, attr): + continue + try: + if self.__getattribute__(attr) != other.__getattribute__(attr): + return False + except AttributeError: + return False + + return True + @property def dirty(self): return len(self.__dirty__) > 0 @@ -249,6 +264,20 @@ def marshall(self, skip_readonly=True): marshalled[key] = val return marshalled + def post(self, json=None, wait=False): + """Access the POST method directly for the object. + + :param wait: If wait is True, then wait here until the operation + completes. + :type wait: bool + :param json: Dictionary that the represents the request body used on the POST method. + :type wait: dict + :raises: :class:`pylxd.exception.LXDAPIException` on error + """ + response = self.api.post(json=json) + if response.json()["type"] == "async" and wait: + self.client.operations.wait_for_operation(response.json()["operation"]) + def put(self, put_object, wait=False): """Access the PUT method directly for the object. diff --git a/pylxd/models/storage_pool.py b/pylxd/models/storage_pool.py index 790d940d..0f0e3900 100644 --- a/pylxd/models/storage_pool.py +++ b/pylxd/models/storage_pool.py @@ -39,8 +39,8 @@ class StoragePool(model.Model): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.resources = StorageResourcesManager(self) - self.volumes = StorageVolumeManager(self) + self.resources = managers.StorageResourcesManager(self) + self.volumes = managers.StorageVolumeManager(self) @classmethod def get(cls, client, name): @@ -249,10 +249,6 @@ def patch(self, patch_object, wait=False): super().patch(patch_object, wait) -class StorageResourcesManager(managers.BaseManager): - manager_for = "pylxd.models.StorageResources" - - class StorageResources(model.Model): """An LXD Storage Resources model. @@ -288,10 +284,6 @@ def get(cls, storage_pool): return resources -class StorageVolumeManager(managers.BaseManager): - manager_for = "pylxd.models.StorageVolume" - - class StorageVolume(model.Model): """An LXD Storage volume. @@ -303,11 +295,14 @@ class StorageVolume(model.Model): name = model.Attribute(readonly=True) type = model.Attribute(readonly=True) + content_type = model.Attribute(readonly=True) description = model.Attribute(readonly=True) config = model.Attribute() used_by = model.Attribute(readonly=True) location = model.Attribute(readonly=True) + snapshots = model.Manager() + storage_pool = model.Parent() @property @@ -323,6 +318,11 @@ def api(self): """ return self.storage_pool.api.volumes[self.type][self.name] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.snapshots = managers.StorageVolumeSnapshotManager(self) + @classmethod def all(cls, storage_pool): """Get all the volumnes for this storage pool. @@ -599,8 +599,9 @@ def delete(self): Implements: DELETE /1.0/storage-pools//volumes// - Deleting a storage volume may fail if it is being used. See the LXD - documentation for further details. + Deleting a storage volume may fail if it is being used. + See https://documentation.ubuntu.com/lxd/en/latest/explanation/storage/#storage-volumes + for further details. :raises: :class:`pylxd.exceptions.LXDAPIExtensionNotAvailable` if the 'storage' api extension is missing. @@ -609,3 +610,227 @@ def delete(self): """ # Note this method exists so that it is documented via sphinx. super().delete() + + def restore_from(self, snapshot_name, wait=False): + """Restore this volume from a snapshot using its name. + + Attempts to restore a volume using a snapshot identified by its name. + + Implements POST /1.0/storage-pools//volumes/custom//snapshot/ + + :param snapshot_name: the name of the snapshot to restore from + :type snapshot_name: str + :param wait: wait until the operation is completed. + :type wait: boolean + :raises: :class:`pylxd.exceptions.LXDAPIExtensionNotAvailable` if the + 'storage' or 'storage_api_volume_snapshots' api extension is missing. + :raises: :class:`pylxd.exceptions.LXDAPIException` if the the operation fails. + :returns: the original response from the restore operation (not the + operation result) + :rtype: :class:`requests.Response` + """ + response = self.api.put(json={"restore": snapshot_name}) + if wait: + self.client.operations.wait_for_operation(response.json()["operation"]) + return response + + +class StorageVolumeSnapshot(model.Model): + """A storage volume snapshot. + + This corresponds to the LXD endpoing at + /1.0/storage-pools//volumes///snapshots + + api_extension: 'storage_api_volume_snapshots' + """ + + name = model.Attribute(readonly=True) + description = model.Attribute() + config = model.Attribute() + content_type = model.Attribute(readonly=True) + # Date strings follow the ISO 8601 pattern + created_at = model.Attribute(readonly=True) + expires_at = model.Attribute() + + _endpoint = "snapshots" + + volume = model.Parent() + + @property + def api(self): + """Provides an object with the endpoint: + + /1.0/storage-pools//volumes///snapshots/ + + Used internally to construct endpoints. + + :returns: an API node with the named endpoint + :rtype: :class:`pylxd.client._APINode` + """ + return self.volume.api[self._endpoint][self.name] + + @classmethod + def get(cls, volume, name): + """Get a :class:`pylxd.models.StorageVolumeSnapshot` by its name. + + Implements GET /1.0/storage-pools//volumes/custom//snapshots/ + + :param client: a storage pool object on which to fetch resources + :type storage_pool: :class:`pylxd.models.storage_pool.StoragePool` + :param _type: the volume type; one of 'container', 'image', 'custom' + :type _type: str + :param volume: the name of the storage volume snapshot to get + :type volume: pylxd.models.StorageVolume + :returns: a storage pool if successful, raises NotFound if not found + :rtype: :class:`pylxd.models.storage_pool.StorageVolume` + :raises: :class:`pylxd.exceptions.NotFound` + :raises: :class:`pylxd.exceptions.LXDAPIExtensionNotAvailable` if the + 'storage' or 'storage_api_volume_snapshots' api extension is missing. + :raises: :class:`pylxd.exceptions.LXDAPIException` if the the operation fails. + """ + volume.client.assert_has_api_extension("storage_api_volume_snapshots") + + response = volume.api.snapshots[name].get() + + snapshot = cls(volume.client, volume=volume, **response.json()["metadata"]) + + # Getting '0001-01-01T00:00:00Z' means that the volume does not have an expiration time set. + if response.json()["metadata"]["expires_at"] == "0001-01-01T00:00:00Z": + snapshot.expires_at = None + + # Snapshot names are namespaced in LXD, as volume-name/snapshot-name. + # We hide that implementation detail. + snapshot.name = snapshot.name.split("/")[-1] + return snapshot + + @classmethod + def all(cls, volume, use_recursion=False): + """Get all :class:`pylxd.models.StorageVolumeSnapshot` objects related to a certain volume. + If use_recursion is unset or set to False, a list of snapshot names is returned. + If use_recursion is set to True, a list of :class:`pylxd.models.StorageVolumeSnapshot` objects is returned + containing additional information for each snapshot. + + Implements GET /1.0/storage-pools//volumes/custom//snapshots/ + + :param volume: The storage volume snapshot to get snapshots from + :type volume: pylxd.models.StorageVolume + :param use_recursion: Specifies whether 'recursion=1' should be used on the request. + :type use_recursion: bool + :returns: A list of storage volume snapshot names if use_recursion is False, otherwise + returns a list of :class:`pylxd.models.StorageVolumeSnapshot` + :rtype: list + :raises: :class:`pylxd.exceptions.LXDAPIExtensionNotAvailable` if the + 'storage' or 'storage_api_volume_snapshots' api extension is missing. + :raises: :class:`pylxd.exceptions.LXDAPIException` if the the operation fails. + """ + volume.client.assert_has_api_extension("storage_api_volume_snapshots") + + if use_recursion: + # Using recursion so returning list of StorageVolumeSnapshot objects. + def parse_response_item(snapshot_json): + snapshot_object = cls(volume.client, volume=volume, **snapshot_json) + snapshot_object.content_type = volume.content_type + # Snapshot names are namespaced in LXD, as volume-name/snapshot-name. + # We hide that implementation detail. + snapshot_object.name = snapshot_object.name.split("/")[-1] + + return snapshot_object + + response = volume.api.snapshots.get(params={"recursion": 1}) + + return [ + parse_response_item(snapshot) + for snapshot in response.json()["metadata"] + ] + + response = volume.api.snapshots.get() + + return [ + snapshot_name.split("/")[-1] + for snapshot_name in response.json()["metadata"] + ] + + @classmethod + def create(cls, volume, name=None, expires_at=None): + """Create new :class:`pylxd.models.StorageVolumeSnapshot` object from the current volume state using the given attributes. + + Implements POST /1.0/storage-pools//volumes/custom//snapshots + + :param volume: :class:`pylxd.models.StorageVolume` object that represents the target volume to take the snapshot from + :type volume: :class:`pylxd.models.StorageVolume` + :param name: Optional parameter. Name of the created snapshot. The snapshot will be called "snap{index}" by default. + :type name: str + :param expires_at: Optional parameter. Expiration time for the created snapshot in ISO 8601 format. No expiration date by default. + :type name: str + :returns: a storage volume snapshot if successful, raises an exception otherwise. + :rtype: :class:`pylxd.models.StorageVolume` + :raises: :class:`pylxd.exceptions.LXDAPIExtensionNotAvailable` if the + 'storage_api_volume_snapshots' api extension is missing. + :raises: :class:`pylxd.exceptions.LXDAPIException` if the the operation fails. + """ + volume.client.assert_has_api_extension("storage_api_volume_snapshots") + + response = volume.api.snapshots.post( + json={"name": name, "expires_at": expires_at} + ) + + operation = volume.client.operations.wait_for_operation( + response.json()["operation"] + ) + + # Extract the snapshot name from the response JSON in case it was not provided + if not name: + name = operation.resources["storage_volume_snapshots"][0].split("/")[-1] + + snapshot = volume.snapshots.get(name) + return snapshot + + @classmethod + def exists(cls, volume, name): + """Determine whether a volume snapshot exists in LXD. + + :param name: Name of the desired snapshot. + :type name: str + :returns: True if a snapshot with the given name exists, returns False otherwise. + :rtype: bool + :raises: `pylxd.exceptions.LXDAPIException` if the the operation fails. + """ + try: + volume.snapshots.get(name) + return True + except cls.NotFound: + return False + + def rename(self, new_name): + """Rename a storage volume snapshot. + + Implements POST /1.0/storage-pools//volumes/custom//snapshot/ + + Renames a storage volume snapshot, changing the endpoints that reference it. + + :raises: :class:`pylxd.exceptions.LXDAPIException` if the the operation fails. + """ + super().post(wait=True, json={"name": new_name}) + self.name = new_name + + def restore(self, wait=False): + """Restore the volume from this snapshot. + + Attempts to restore a custom volume using this snapshot. + Equivalent to pylxd.models.StorageVolume.restore_from(this_snapshot). + + :param wait: wait until the operation is completed. + :type wait: boolean + :raises: :class:`pylxd.exceptions.LXDAPIException` if the the operation fails. + """ + self.volume.restore_from(self.name, wait) + + def delete(self, wait=False): + """Delete this storage pool snapshot. + + Implements: DELETE /1.0/storage-pools//volumes/custom//snapshot/ + + :raises: :class:`pylxd.exceptions.LXDAPIException` if the storage pool + can't be deleted. + """ + super().delete(wait=wait)