From 272cc16455c3cca753c954dd28ae553d98f4863e Mon Sep 17 00:00:00 2001 From: ZuluPro Date: Sun, 26 Jul 2015 04:16:37 -0400 Subject: [PATCH] Added Runabove volume management & fixed many little things --- docs/compute/drivers/runabove.rst | 15 +- .../compute/runabove/attach_volume.py | 11 + libcloud/compute/drivers/runabove.py | 297 ++++++++++++++---- .../compute/fixtures/runabove/volume_get.json | 1 + .../fixtures/runabove/volume_get_detail.json | 1 + libcloud/test/compute/test_runabove.py | 53 ++++ 6 files changed, 312 insertions(+), 66 deletions(-) create mode 100644 docs/examples/compute/runabove/attach_volume.py create mode 100644 libcloud/test/compute/fixtures/runabove/volume_get.json create mode 100644 libcloud/test/compute/fixtures/runabove/volume_get_detail.json diff --git a/docs/compute/drivers/runabove.rst b/docs/compute/drivers/runabove.rst index adf1b9d309..2b3c476d66 100644 --- a/docs/compute/drivers/runabove.rst +++ b/docs/compute/drivers/runabove.rst @@ -1,5 +1,5 @@ -Cloudwatt Compute Driver Documentation -====================================== +RunAbove Compute Driver Documentation +===================================== `RunAbove`_ is a public cloud offer created by OVH Group with datacenters in North America and Europe. @@ -18,7 +18,7 @@ Instantiating a driver When you instantiate a driver you need to pass the following arguments to the driver constructor: -* ``user_id`` - Application key +* ``key`` - Application key * ``secret`` - Application secret * ``ex_consumer_key`` - Consumer key @@ -55,11 +55,16 @@ Now you have and can use you credentials with Libcloud. Examples -------- -Create instance -~~~~~~~~~~~~~~~ +Create node +~~~~~~~~~~~ .. literalinclude:: /examples/compute/runabove/create_node.py +Create and attach a volume to a node +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. literalinclude:: /examples/compute/runabove/attach_volume.py + API Docs -------- diff --git a/docs/examples/compute/runabove/attach_volume.py b/docs/examples/compute/runabove/attach_volume.py new file mode 100644 index 0000000000..c4c0178b15 --- /dev/null +++ b/docs/examples/compute/runabove/attach_volume.py @@ -0,0 +1,11 @@ +from libcloud.compute.types import Provider +from libcloud.compute.providers import get_driver + +RunAbove = get_driver(Provider.RUNABOVE) +driver = RunAbove('yourAppKey', 'yourAppSecret', 'YourConsumerKey') + +location = [l for l in driver.list_locations() if l.id == 'SBG-1'][0] +node = driver.list_nodes()[0] + +volume = driver.create_volume(size=10, location=location) +driver.attach_volume(node=node, volume=volume) diff --git a/libcloud/compute/drivers/runabove.py b/libcloud/compute/drivers/runabove.py index a3ff95b895..e9c92f2e0e 100644 --- a/libcloud/compute/drivers/runabove.py +++ b/libcloud/compute/drivers/runabove.py @@ -12,11 +12,13 @@ # 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. - +""" +RunAbove driver +""" from libcloud.common.runabove import API_ROOT, RunAboveConnection from libcloud.compute.base import NodeDriver, NodeSize, Node, NodeLocation -from libcloud.compute.base import NodeImage -from libcloud.compute.types import Provider +from libcloud.compute.base import NodeImage, StorageVolume +from libcloud.compute.types import Provider, StorageVolumeState from libcloud.compute.drivers.openstack import OpenStackNodeDriver from libcloud.compute.drivers.openstack import OpenStackKeyPair @@ -25,24 +27,9 @@ class RunAboveNodeDriver(NodeDriver): """ Libcloud driver for the RunAbove API - Rough mapping of which is which: - - list_nodes linode.list - reboot_node linode.reboot - destroy_node linode.delete - create_node linode.create, linode.update, - linode.disk.createfromdistribution, - linode.disk.create, linode.config.create, - linode.ip.addprivate, linode.boot - list_sizes avail.linodeplans - list_images avail.distributions - list_locations avail.datacenters - list_volumes linode.disk.list - destroy_volume linode.disk.delete - - For more information on the Linode API, be sure to read the reference: + For more information on the RunAbove API, read the official reference: - http://www.linode.com/api/ + https://api.runabove.com/console/ """ type = Provider.RUNABOVE name = "RunAbove" @@ -52,13 +39,20 @@ class RunAboveNodeDriver(NodeDriver): api_name = 'runabove' NODE_STATE_MAP = OpenStackNodeDriver.NODE_STATE_MAP + VOLUME_STATE_MAP = OpenStackNodeDriver.VOLUME_STATE_MAP def __init__(self, key, secret, ex_consumer_key=None): """ - Instantiate the driver with the given API key + Instantiate the driver with the given API credentials. - :param key: the API key to use (required) - :type key: ``str`` + :param key: Your application key (required) + :type key: ``str`` + + :param secret: Your application secret (required) + :type secret: ``str`` + + :param ex_consumer_key: Your consumer key (required) + :type ex_consumer_key: ``str`` :rtype: ``None`` """ @@ -68,14 +62,12 @@ def __init__(self, key, secret, ex_consumer_key=None): def list_nodes(self, location=None): """ - List all Linodes that the API key can access + List all nodes. - This call will return all Linodes that the API key in use has access - to. - If a node is in this list, rebooting will work; however, creation and - destruction are a separate grant. + :keyword location: Location (region) used as filter + :type location: :class:`NodeLocation` - :return: List of node objects that the API key can access + :return: List of node objects :rtype: ``list`` of :class:`Node` """ action = API_ROOT + '/instance' @@ -86,20 +78,50 @@ def list_nodes(self, location=None): return self._to_nodes(response.object) def ex_get_node(self, node_id): + """ + Get a individual node. + + :keyword node_id: Node's ID + :type node_id: ``str`` + + :return: Created node + :rtype : :class:`Node` + """ action = API_ROOT + '/instance/' + node_id response = self.connection.request(action, method='GET') return self._to_node(response.object) - def create_node(self, **kwargs): + def create_node(self, name, image, size, location, ex_keyname=None): + """ + Create a new node + + :keyword name: Name of created node + :type name: ``str`` + + :keyword image: Image used for node + :type image: :class:`NodeImage` + + :keyword size: Size (flavor) used for node + :type size: :class:`NodeSize` + + :keyword location: Location (region) where to create node + :type location: :class:`NodeLocation` + + :keyword ex_keyname: Name of SSH key used + :type ex_keyname: ``str`` + + :retunrs: Created node + :rtype : :class:`Node` + """ action = API_ROOT + '/instance' data = { - 'name': kwargs["name"], - 'imageId': kwargs["image"].id, - 'flavorId': kwargs["size"].id, - 'region': kwargs["location"].id, + 'name': name, + 'imageId': image.id, + 'flavorId': size.id, + 'region': location.id, } - if kwargs.get('ex_keyname'): - data['sshKeyName'] = kwargs['ex_keyname'] + if ex_keyname is not None: + data['sshKeyName'] = ex_keyname response = self.connection.request(action, data=data, method='POST') return self._to_node(response.object) @@ -109,14 +131,6 @@ def destroy_node(self, node): return True def list_sizes(self, location=None): - """ - List available RunAbove flavors. - - :keyword location: the facility to retrieve plans in - :type location: :class:`NodeLocation` - - :rtype: ``list`` of :class:`NodeSize` - """ action = API_ROOT + '/flavor' data = {} if location: @@ -125,24 +139,38 @@ def list_sizes(self, location=None): return self._to_sizes(response.object) def ex_get_size(self, size_id): + """ + Get an individual size (flavor). + + :keyword size_id: Size's ID + :type size_id: ``str`` + + :return: Size + :rtype: :class:`NodeSize` + """ action = API_ROOT + '/flavor/' + size_id response = self.connection.request(action) return self._to_size(response.object) - def list_images(self, location=None, size=None): + def list_images(self, location=None, ex_size=None): """ - List available Linux distributions + List available images + + :keyword location: Location (region) used as filter + :type location: :class:`NodeLocation` - Retrieve all Linux distributions that can be deployed to a Linode. + :keyword ex_size: Exclude images which are uncompatible with given size + :type ex_size: :class:`NodeImage` - :rtype: ``list`` of :class:`NodeImage` + :return: List of images + :rtype : ``list`` of :class:`NodeImage` """ action = API_ROOT + '/image' data = {} if location: data['region'] = location.id - if size: - data['flavorId'] = size.id + if ex_size: + data['flavorId'] = ex_size.id response = self.connection.request(action, data=data) return self._to_images(response.object) @@ -152,18 +180,20 @@ def get_image(self, image_id): return self._to_image(response.object) def list_locations(self): - """ - List available facilities for deployment - - Retrieve all facilities that a Linode can be deployed in. - - :rtype: ``list`` of :class:`NodeLocation` - """ action = API_ROOT + '/region' data = self.connection.request(action) return self._to_locations(data.object) def list_key_pairs(self, location=None): + """ + List available SSH public keys. + + :keyword location: Location (region) used as filter + :type location: :class:`NodeLocation` + + :return: Public keys + :rtype: ``list``of :class:`KeyPair` + """ action = API_ROOT + '/ssh' data = {} if location: @@ -172,6 +202,18 @@ def list_key_pairs(self, location=None): return self._to_key_pairs(response.object) def get_key_pair(self, name, location): + """ + Get an individual SSH public key by its name and location. + + :keyword name: SSH key name + :type name: str + + :keyword location: Key's region + :type location: :class:`NodeLocation` + + :return: Public key + :rtype: :class:`KeyPair` + """ action = API_ROOT + '/ssh/' + name data = {'region': location.id} response = self.connection.request(action, data=data) @@ -179,7 +221,7 @@ def get_key_pair(self, name, location): def import_key_pair_from_string(self, name, key_material, location): """ - Import a new public key. + Import a new public key from string. :param name: Key pair name. :type name: ``str`` @@ -188,7 +230,7 @@ def import_key_pair_from_string(self, name, key_material, location): :type key_material: ``str`` :return: Imported key pair object. - :rtype: :class:`.KeyPair` + :rtype: :class:`KeyPair` """ action = API_ROOT + '/ssh' data = {'name': name, 'publicKey': key_material, 'region': location.id} @@ -199,8 +241,11 @@ def delete_key_pair(self, name, location): """ Delete an existing key pair. - :param key_pair: Key pair object or ID. - :type key_pair: :class.KeyPair` or ``int`` + :param name: Key pair name. + :type name: ``str`` + + :keyword location: Key's region + :type location: :class:`NodeLocation` :return: True of False based on success of Keypair deletion :rtype: ``bool`` @@ -210,6 +255,136 @@ def delete_key_pair(self, name, location): self.connection.request(action, data=data, method='DELETE') return True + def create_volume(self, size, location, name=None, + ex_volume_type='classic', ex_description=None): + """ + Create a volume. + + :param size: Size of volume to create (in GB). + :type size: ``int`` + + :param name: Name of volume to create + :type name: ``str`` + + :keyword location: Location to create the volume in + :type location: :class:`NodeLocation` or ``None`` + + :keyword ex_volume_type: ``'classic'`` or ``'high-speed'`` + :type ex_volume_type: ``str`` + + :keyword ex_description: Optionnal description of volume + :type ex_description: str + + :return: Storage Volume object + :rtype: :class:`StorageVolume` + """ + action = API_ROOT + '/volume' + data = { + 'region': location.id, + 'size': str(size), + 'type': ex_volume_type, + } + if name: + data['name'] = name + if ex_description: + data['description'] = ex_description + response = self.connection.request(action, data=data, method='POST') + return self._to_volume(response.object) + + def destroy_volume(self, volume): + action = API_ROOT + '/volume/' + volume.id + self.connection.request(action, method='DELETE') + return True + + def list_volumes(self, location=None): + """ + Return a list of volumes. + + :keyword location: Location use for filter + :type location: :class:`NodeLocation` or ``None`` + + :return: A list of volume objects. + :rtype: ``list`` of :class:`StorageVolume` + """ + action = API_ROOT + '/volume' + data = {} + if location: + data['region'] = location.id + response = self.connection.request(action, data=data) + return self._to_volumes(response.object) + + def ex_get_volume(self, volume_id): + """ + Return a Volume object based on a volume ID. + + :param volume_id: The ID of the volume + :type volume_id: ``int`` + + :return: A StorageVolume object for the volume + :rtype: :class:`StorageVolume` + """ + action = API_ROOT + '/volume/' + volume_id + response = self.connection.request(action) + return self._to_volume(response.object) + + def attach_volume(self, node, volume, device=None): + """ + Attach a volume to a node. + + :param node: Node where to attach volume + :type node: :class:`Node` + + :param volume: The ID of the volume + :type volume: :class:`StorageVolume` + + :param device: Unsed parameter + + :return: True or False representing operation successful + :rtype: ``bool`` + """ + action = '%s/volume/%s/attach' % (API_ROOT, volume.id) + data = {'instanceId': node.id} + self.connection.request(action, data=data, method='POST') + return True + + def detach_volume(self, volume, ex_node=None): + """ + Detach a volume to a node. + + :param volume: The ID of the volume + :type volume: :class:`StorageVolume` + + :param ex_node: Node to detach from (optionnal if volume is attached + to only one node) + :type ex_node: :class:`Node` + + :return: True or False representing operation successful + :rtype: ``bool`` + + :raises: Exception: If ``ex_node`` is not provided and more than one + node is attached to the volume + """ + action = '%s/volume/%s/detach' % (API_ROOT, volume.id) + if ex_node is None: + if len(volume.extra['attachedTo']) != 1: + err_msg = "Volume '%s' has more or less than one attached \ + nodes, you must specify one." + raise Exception(err_msg) + ex_node = self.ex_get_node(volume.extra['attachedTo'][0]) + data = {'instanceId': ex_node.id} + self.connection.request(action, data=data, method='POST') + return True + + def _to_volume(self, obj): + extra = obj.copy() + extra.pop('id') + extra.pop('name') + extra.pop('size') + state = self.VOLUME_STATE_MAP.get(obj.pop('status', None), + StorageVolumeState.UNKNOWN) + return StorageVolume(id=obj['id'], name=obj['name'], size=obj['size'], + state=state, extra=extra, driver=self) + def _to_volumes(self, objs): return [self._to_volume(obj) for obj in objs] diff --git a/libcloud/test/compute/fixtures/runabove/volume_get.json b/libcloud/test/compute/fixtures/runabove/volume_get.json new file mode 100644 index 0000000000..aa0b36026e --- /dev/null +++ b/libcloud/test/compute/fixtures/runabove/volume_get.json @@ -0,0 +1 @@ +[{"id": "foo", "attachedTo": [], "created": "2015-08-09T15:13:59.459187Z", "name": "testvol", "description": "", "size": 10, "status": "creating", "region": "SBG-1", "type": "classic" }] diff --git a/libcloud/test/compute/fixtures/runabove/volume_get_detail.json b/libcloud/test/compute/fixtures/runabove/volume_get_detail.json new file mode 100644 index 0000000000..861cb4e77d --- /dev/null +++ b/libcloud/test/compute/fixtures/runabove/volume_get_detail.json @@ -0,0 +1 @@ +{"id": "foo", "attachedTo": [], "created": "2015-08-09T15:13:59.459187Z", "name": "testvol", "description": "", "size": 10, "status": "creating", "region": "SBG-1", "type": "classic" } diff --git a/libcloud/test/compute/test_runabove.py b/libcloud/test/compute/test_runabove.py index 54e881e49a..a1538e0fdb 100644 --- a/libcloud/test/compute/test_runabove.py +++ b/libcloud/test/compute/test_runabove.py @@ -80,6 +80,29 @@ def _json_1_0_instance_post(self, method, url, body, headers): body = self.fixtures.load('instance_get_detail.json') return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + def _json_1_0_volume_get(self, method, url, body, headers): + body = self.fixtures.load('volume_get.json') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _json_1_0_volume_post(self, method, url, body, headers): + body = self.fixtures.load('volume_get_detail.json') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _json_1_0_volume_foo_get(self, method, url, body, headers): + body = self.fixtures.load('volume_get_detail.json') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _json_1_0_volume_foo_delete(self, method, url, body, headers): + return (httplib.OK, '', {}, httplib.responses[httplib.OK]) + + def _json_1_0_volume_foo_attach_post(self, method, url, body, headers): + body = self.fixtures.load('volume_get_detail.json') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _json_1_0_volume_foo_detach_post(self, method, url, body, headers): + body = self.fixtures.load('volume_get_detail.json') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + @patch('libcloud.common.runabove.RunAboveConnection._timedelta', 42) class RunAboveTests(unittest.TestCase): @@ -144,5 +167,35 @@ def test_destroy_node(self): node = self.driver.list_nodes()[0] self.driver.destroy_node(node) + def test_list_volumes(self): + volumes = self.driver.list_volumes() + self.assertTrue(len(volumes) > 0) + + def test_get_volume(self): + volume = self.driver.ex_get_volume('foo') + self.assertEqual(volume.name, 'testvol') + + def test_create_volume(self): + location = self.driver.list_locations()[0] + volume = self.driver.create_volume(size=10, name='testvol', + location=location) + self.assertEqual(volume.name, 'testvol') + + def test_destroy_volume(self): + volume = self.driver.list_volumes()[0] + self.driver.destroy_volume(volume) + + def test_attach_volume(self): + node = self.driver.list_nodes()[0] + volume = self.driver.ex_get_volume('foo') + response = self.driver.attach_volume(node=node, volume=volume) + self.assertTrue(response) + + def test_detach_volume(self): + node = self.driver.list_nodes()[0] + volume = self.driver.ex_get_volume('foo') + response = self.driver.detach_volume(ex_node=node, volume=volume) + self.assertTrue(response) + if __name__ == '__main__': sys.exit(unittest.main())