#!/usr/bin/env python # -*- coding: utf-8 -*- # Copyright 2012 Rackspace # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. from functools import wraps import time import six import pyrax from pyrax.client import BaseClient import pyrax.exceptions as exc from pyrax.manager import BaseManager from pyrax.resource import BaseResource MIN_SIZE = 100 MAX_SIZE = 1024 RETRY_INTERVAL = 5 def _resolve_id(val): """Takes an object or an ID and returns the ID.""" return val if isinstance(val, six.string_types) else val.id def _resolve_name(val): """Takes an object or a name and returns the name.""" return val if isinstance(val, six.string_types) else val.name def assure_volume(fnc): """ Converts a volumeID passed as the volume to a CloudBlockStorageVolume object. """ @wraps(fnc) def _wrapped(self, volume, *args, **kwargs): if not isinstance(volume, CloudBlockStorageVolume): # Must be the ID volume = self._manager.get(volume) return fnc(self, volume, *args, **kwargs) return _wrapped def assure_snapshot(fnc): """ Converts a snapshot ID passed as the snapshot to a CloudBlockStorageSnapshot object. """ @wraps(fnc) def _wrapped(self, snapshot, *args, **kwargs): if not isinstance(snapshot, CloudBlockStorageSnapshot): # Must be the ID snapshot = self._snapshot_manager.get(snapshot) return fnc(self, snapshot, *args, **kwargs) return _wrapped class CloudBlockStorageSnapshot(BaseResource): """ This class represents a Snapshot (copy) of a Block Storage Volume. """ def delete(self): """ Adds a check to make sure that the snapshot is able to be deleted. """ if self.status not in ("available", "error"): raise exc.SnapshotNotAvailable("Snapshot must be in 'available' " "or 'error' status before deleting. Current status: %s" % self.status) # When there are more thann one snapshot for a given volume, attempting to # delete them all will throw a 409 exception. This will help by retrying # such an error once after a RETRY_INTERVAL second delay. try: super(CloudBlockStorageSnapshot, self).delete() except exc.ClientException as e: if "Request conflicts with in-progress 'DELETE" in str(e): time.sleep(RETRY_INTERVAL) # Try again; if it fails, oh, well... super(CloudBlockStorageSnapshot, self).delete() def _get_name(self): return self.display_name def _set_name(self, val): self.display_name = val name = property(_get_name, _set_name, None, "Convenience for referencing the display_name.") def _get_description(self): return self.display_description def _set_description(self, val): self.display_description = val description = property(_get_description, _set_description, None, "Convenience for referencing the display_description.") class CloudBlockStorageVolumeType(BaseResource): """ This class represents a Block Storage Volume Type. """ pass class CloudBlockStorageVolume(BaseResource): """ This class represents a Block Storage volume. """ def __init__(self, *args, **kwargs): super(CloudBlockStorageVolume, self).__init__(*args, **kwargs) region = self.manager.api.region_name self._nova_volumes = pyrax.connect_to_cloudservers(region).volumes def attach_to_instance(self, instance, mountpoint): """ Attaches this volume to the cloud server instance at the specified mountpoint. This requires a call to the cloud servers API; it cannot be done directly. """ instance_id = _resolve_id(instance) try: resp = self._nova_volumes.create_server_volume(instance_id, self.id, mountpoint) except Exception as e: raise exc.VolumeAttachmentFailed("%s" % e) def detach(self): """ Detaches this volume from any device it may be attached to. If it is not attached, nothing happens. """ attachments = self.attachments if not attachments: # Not attached; no error needed, just return return # A volume can only be attached to one device at a time, but for some # reason this is a list instead of a singular value att = attachments[0] instance_id = att["server_id"] attachment_id = att["id"] try: self._nova_volumes.delete_server_volume(instance_id, attachment_id) except Exception as e: raise exc.VolumeDetachmentFailed("%s" % e) def delete(self, force=False): """ Volumes cannot be deleted if either a) they are attached to a device, or b) they have any snapshots. This method overrides the base delete() method to both better handle these failures, and also to offer a 'force' option. When 'force' is True, the volume is detached, and any dependent snapshots are deleted before calling the volume's delete. """ if force: self.detach() self.delete_all_snapshots() try: super(CloudBlockStorageVolume, self).delete() except exc.VolumeNotAvailable: # Notify the user? Record it somewhere? # For now, just re-raise raise def create_snapshot(self, name=None, description=None, force=False): """ Creates a snapshot of this volume, with an optional name and description. Normally snapshots will not happen if the volume is attached. To override this default behavior, pass force=True. """ name = name or "" description = description or "" # Note that passing in non-None values is required for the _create_body # method to distinguish between this and the request to create and # instance. return self.manager.create_snapshot(volume=self, name=name, description=description, force=force) def list_snapshots(self): """ Returns a list of all snapshots of this volume. """ return [snap for snap in self.manager.list_snapshots() if snap.volume_id == self.id] def delete_all_snapshots(self): """ Locates all snapshots of this volume and deletes them. """ for snap in self.list_snapshots(): snap.delete() def _get_name(self): return self.display_name def _set_name(self, val): self.display_name = val name = property(_get_name, _set_name, None, "Convenience for referencing the display_name.") def _get_description(self): return self.display_description def _set_description(self, val): self.display_description = val description = property(_get_description, _set_description, None, "Convenience for referencing the display_description.") class CloudBlockStorageManager(BaseManager): """ Manager class for Cloud Block Storage. """ def _create_body(self, name, size=None, volume_type=None, description=None, metadata=None, snapshot_id=None, clone_id=None, availability_zone=None): """ Used to create the dict required to create a new volume """ if not isinstance(size, (int, long)) or not ( MIN_SIZE <= size <= MAX_SIZE): raise exc.InvalidSize("Volume sizes must be integers between " "%s and %s." % (MIN_SIZE, MAX_SIZE)) if volume_type is None: volume_type = "SATA" if description is None: description = "" if metadata is None: metadata = {} body = {"volume": { "size": size, "snapshot_id": snapshot_id, "source_volid": clone_id, "display_name": name, "display_description": description, "volume_type": volume_type, "metadata": metadata, "availability_zone": availability_zone, }} return body def create(self, *args, **kwargs): """ Catches errors that may be returned, and raises more informational exceptions. """ try: return super(CloudBlockStorageManager, self).create(*args, **kwargs) except exc.BadRequest as e: msg = e.message if "Clones currently must be >= original volume size" in msg: raise exc.VolumeCloneTooSmall(msg) else: raise def list_snapshots(self): """ Pass-through method to allow the list_snapshots() call to be made directly on a volume. """ return self.api.list_snapshots() def create_snapshot(self, volume, name, description=None, force=False): """ Pass-through method to allow the create_snapshot() call to be made directly on a volume. """ return self.api.create_snapshot(volume, name, description=description, force=force) class CloudBlockStorageSnapshotManager(BaseManager): """ Manager class for Cloud Block Storage. """ def _create_body(self, name, description=None, volume=None, force=False): """ Used to create the dict required to create a new snapshot """ body = {"snapshot": { "display_name": name, "display_description": description, "volume_id": volume.id, "force": str(force).lower(), }} return body def create(self, name, volume, description=None, force=False): """ Adds exception handling to the default create() call. """ try: snap = super(CloudBlockStorageSnapshotManager, self).create( name=name, volume=volume, description=description, force=force) except exc.BadRequest as e: msg = str(e) if "Invalid volume: must be available" in msg: # The volume for the snapshot was attached. raise exc.VolumeNotAvailable("Cannot create a snapshot from an " "attached volume. Detach the volume before trying " "again, or pass 'force=True' to the create_snapshot() " "call.") else: # Some other error raise except exc.ClientException as e: if e.code == 409: if "Request conflicts with in-progress" in str(e): txt = ("The volume is current creating a snapshot. You " "must wait until that completes before attempting " "to create an additional snapshot.") raise exc.VolumeNotAvailable(txt) else: raise else: raise return snap class CloudBlockStorageClient(BaseClient): """ This is the primary class for interacting with Cloud Block Storage. """ name = "Cloud Block Storage" def _configure_manager(self): """ Create the manager to handle the instances, and also another to handle flavors. """ self._manager = CloudBlockStorageManager(self, resource_class=CloudBlockStorageVolume, response_key="volume", uri_base="volumes") self._types_manager = BaseManager(self, resource_class=CloudBlockStorageVolumeType, response_key="volume_type", uri_base="types") self._snapshot_manager = CloudBlockStorageSnapshotManager(self, resource_class=CloudBlockStorageSnapshot, response_key="snapshot", uri_base="snapshots") def list_types(self): """Returns a list of all available volume types.""" return self._types_manager.list() def list_snapshots(self): """Returns a list of all snapshots.""" return self._snapshot_manager.list() @assure_volume def attach_to_instance(self, volume, instance, mountpoint): """Attaches the volume to the specified instance at the mountpoint.""" return volume.attach_to_instance(instance, mountpoint) @assure_volume def detach(self, volume): """Detaches the volume from whatever device it is attached to.""" return volume.detach() @assure_volume def delete_volume(self, volume, force=False): """Deletes the volume.""" return volume.delete(force=force) @assure_volume def create_snapshot(self, volume, name=None, description=None, force=False): """ Creates a snapshot of the volume, with an optional name and description. Normally snapshots will not happen if the volume is attached. To override this default behavior, pass force=True. """ return self._snapshot_manager.create(volume=volume, name=name, description=description, force=force) @assure_snapshot def delete_snapshot(self, snapshot): """Deletes the snapshot.""" return snapshot.delete()