diff --git a/docs/compute/drivers/gandi.rst b/docs/compute/drivers/gandi.rst new file mode 100644 index 0000000000..02ec9a4f2b --- /dev/null +++ b/docs/compute/drivers/gandi.rst @@ -0,0 +1,36 @@ +Gandi Computer Driver Documentation +=================================== + +`Gandi SAS`_ is a registrar, web hosting and private and `public cloud`_ +provider based in France with data centers in France, Luxembourg and USA. + +.. figure:: /_static/images/provider_logos/gandi.png + :align: center + :width: 300 + :target: https://www.gandi.net/ + +Instantiating a driver +---------------------- + +When you instantiate a driver you need to pass the API key and activate +the API platforms. See this `Gandi's documentation`_ for how to do it. + +Examples +-------- + +Create instance +~~~~~~~~~~~~~~~ + +.. literalinclude:: /examples/compute/gandi/create_node.py + + +.. _`Gandi SAS`: https://www.gandi.net/ +.. _`public cloud`: https://www.gandi.net/hebergement/serveur +.. _`Gandi's documentation`: https://wiki.gandi.net/en/xml-api/activate + +API Docs +-------- + +.. autoclass:: libcloud.compute.drivers.gandi.GandiNodeDriver + :members: + :inherited-members: diff --git a/docs/examples/compute/gandi/create_node.py b/docs/examples/compute/gandi/create_node.py new file mode 100644 index 0000000000..213f9ebecd --- /dev/null +++ b/docs/examples/compute/gandi/create_node.py @@ -0,0 +1,12 @@ +from libcloud.compute.types import Provider +from libcloud.compute.providers import get_driver + +Gandi = get_driver(Provider.GANDI) +driver = Gandi('api_key') + +image = [i for i in driver.list_images() if 'Debian 8 64' in i.name][0] +size = [s for s in driver.list_sizes() if s.name == 'Medium instance'][0] +location = [l for l in driver.list_locations() if l.name == 'Equinix Paris'][0] + +node = driver.create_node(name='yournode', size=size, image=image, + location=location, login="youruser", password="pass") diff --git a/libcloud/compute/drivers/cloudstack.py b/libcloud/compute/drivers/cloudstack.py index b226c558a7..41b5c06ef0 100644 --- a/libcloud/compute/drivers/cloudstack.py +++ b/libcloud/compute/drivers/cloudstack.py @@ -2260,6 +2260,14 @@ def list_key_pairs(self, **kwargs): return key_pairs def get_key_pair(self, name): + """ + Retrieve a single key pair. + + :param name: Name of the key pair to retrieve. + :type name: ``str`` + + :rtype: :class:`.KeyPair` + """ params = {'name': name} res = self._sync_request(command='listSSHKeyPairs', params=params, diff --git a/libcloud/compute/drivers/gandi.py b/libcloud/compute/drivers/gandi.py index e5593b4684..844850a325 100644 --- a/libcloud/compute/drivers/gandi.py +++ b/libcloud/compute/drivers/gandi.py @@ -20,6 +20,7 @@ from libcloud.common.gandi import BaseGandiDriver, GandiException,\ NetworkInterface, IPAddress, Disk +from libcloud.compute.base import KeyPair from libcloud.compute.base import StorageVolume from libcloud.compute.types import NodeState, Provider from libcloud.compute.base import Node, NodeDriver @@ -144,6 +145,12 @@ def _to_volumes(self, disks): return [self._to_volume(d) for d in disks] def list_nodes(self): + """ + Return a list of nodes in the current zone or all zones. + + :return: List of Node objects + :rtype: ``list`` of :class:`Node` + """ vms = self.connection.request('hosting.vm.list').object ips = self.connection.request('hosting.ip.list').object for vm in vms: @@ -157,7 +164,37 @@ def list_nodes(self): nodes = self._to_nodes(vms) return nodes + def ex_get_node(self, node_id): + """ + Return a Node object based on a node id. + + :param name: The ID of the node + :type name: ``int`` + + :return: A Node object for the node + :rtype: :class:`Node` + """ + vm = self.connection.request('hosting.vm.info', int(node_id)).object + ips = self.connection.request('hosting.ip.list').object + vm['ips'] = [] + for ip in ips: + if vm['ifaces_id'][0] == ip['iface_id']: + ip = ip.get('ip', None) + if ip: + vm['ips'].append(ip) + node = self._to_node(vm) + return node + def reboot_node(self, node): + """ + Reboot a node. + + :param node: Node to be rebooted + :type node: :class:`Node` + + :return: True if successful, False if not + :rtype: ``bool`` + """ op = self.connection.request('hosting.vm.reboot', int(node.id)) self._wait_operation(op.object['id']) vm = self._node_info(int(node.id)) @@ -166,6 +203,15 @@ def reboot_node(self, node): return False def destroy_node(self, node): + """ + Destroy a node. + + :param node: Node object to destroy + :type node: :class:`Node` + + :return: True if successful + :rtype: ``bool`` + """ vm = self._node_info(node.id) if vm['state'] == 'running': # Send vm_stop and wait for accomplish @@ -214,12 +260,15 @@ def create_node(self, **kwargs): :keyword inet_family: version of ip to use, default 4 (optional) :type inet_family: ``int`` + :keyword keypairs: IDs of keypairs or Keypairs object + :type keypairs: list of ``int`` or :class:`.KeyPair` + :rtype: :class:`Node` """ - if kwargs.get('login') is None or kwargs.get('password') is None: - raise GandiException( - 1020, 'login and password must be defined for node creation') + if not kwargs.get('login') and not kwargs.get('keypairs'): + raise GandiException(1020, "Login and password or ssh keypair " + "must be defined for node creation") location = kwargs.get('location') if location and isinstance(location, NodeLocation): @@ -233,6 +282,12 @@ def create_node(self, **kwargs): raise GandiException( 1022, 'size must be a subclass of NodeSize') + keypairs = kwargs.get('keypairs', []) + keypair_ids = [ + k if isinstance(k, int) else k.extra['id'] + for k in keypairs + ] + # If size name is in INSTANCE_TYPE we use new rating model instance = INSTANCE_TYPES.get(size.id) cores = instance['cpu'] if instance else int(size.id) @@ -247,14 +302,20 @@ def create_node(self, **kwargs): vm_spec = { 'datacenter_id': dc_id, 'hostname': kwargs['name'], - 'login': kwargs['login'], - 'password': kwargs['password'], # TODO : use NodeAuthPassword 'memory': int(size.ram), 'cores': cores, 'bandwidth': int(size.bandwidth), 'ip_version': kwargs.get('inet_family', 4), } + if kwargs.get('login') and kwargs.get('password'): + vm_spec.update({ + 'login': kwargs['login'], + 'password': kwargs['password'], # TODO : use NodeAuthPassword + }) + if keypair_ids: + vm_spec['keys'] = keypair_ids + # Call create_from helper api. Return 3 operations : disk_create, # iface_create,vm_create (op_disk, op_iface, op_vm) = self.connection.request( @@ -284,6 +345,15 @@ def _to_image(self, img): ) def list_images(self, location=None): + """ + Return a list of image objects. + + :keyword location: Which data center to filter a images in. + :type location: :class:`NodeLocation` + + :return: List of GCENodeImage objects + :rtype: ``list`` of :class:`GCENodeImage` + """ try: if location: filtering = {'datacenter_id': int(location.id)} @@ -322,6 +392,15 @@ def list_instance_type(self, location=None): for name, instance in INSTANCE_TYPES.items()] def list_sizes(self, location=None): + """ + Return a list of sizes (machineTypes) in a zone. + + :keyword location: Which data center to filter a sizes in. + :type location: :class:`NodeLocation` or ``None`` + + :return: List of NodeSize objects + :rtype: ``list`` of :class:`NodeSize` + """ account = self.connection.request('hosting.account.info').object if account.get('rating_enabled'): # This account use new rating model @@ -363,18 +442,57 @@ def _to_loc(self, loc): ) def list_locations(self): + """ + Return a list of locations (datacenters). + + :return: List of NodeLocation objects + :rtype: ``list`` of :class:`NodeLocation` + """ res = self.connection.request('hosting.datacenter.list') return [self._to_loc(l) for l in res.object] def list_volumes(self): """ + Return a list of volumes. + :return: A list of volume objects. :rtype: ``list`` of :class:`StorageVolume` """ res = self.connection.request('hosting.disk.list', {}) return self._to_volumes(res.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` + """ + res = self.connection.request('hosting.disk.info', volume_id) + return self._to_volume(res.object) + def create_volume(self, size, name, location=None, snapshot=None): + """ + Create a volume (disk). + + :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 (zone) to create the volume in + :type location: :class:`NodeLocation` or ``None`` + + :keyword snapshot: Snapshot to create image from + :type snapshot: :class:`Snapshot` + + :return: Storage Volume object + :rtype: :class:`StorageVolume` + """ disk_param = { 'name': name, 'size': int(size), @@ -391,6 +509,21 @@ def create_volume(self, size, name, location=None, snapshot=None): return None def attach_volume(self, node, volume, device=None): + """ + Attach a volume to a node. + + :param node: The node to attach the volume to + :type node: :class:`Node` + + :param volume: The volume to attach. + :type volume: :class:`StorageVolume` + + :keyword device: Not used in this cloud. + :type device: ``None`` + + :return: True if successful + :rtype: ``bool`` + """ op = self.connection.request('hosting.vm.disk_attach', int(node.id), int(volume.id)) if self._wait_operation(op.object['id']): @@ -416,6 +549,15 @@ def detach_volume(self, node, volume): return False def destroy_volume(self, volume): + """ + Destroy a volume. + + :param volume: Volume object to destroy + :type volume: :class:`StorageVolume` + + :return: True if successful + :rtype: ``bool`` + """ op = self.connection.request('hosting.disk.delete', int(volume.id)) if self._wait_operation(op.object['id']): return True @@ -534,7 +676,6 @@ def ex_node_attach_interface(self, node, iface): :param node: Node which should be used :type node: :class:`Node` - :param iface: Network interface which should be used :type iface: :class:`GandiNetworkInterface` @@ -553,7 +694,6 @@ def ex_node_detach_interface(self, node, iface): :param node: Node which should be used :type node: :class:`Node` - :param iface: Network interface which should be used :type iface: :class:`GandiNetworkInterface` @@ -617,3 +757,69 @@ def ex_update_disk(self, disk, new_size=None, new_name=None): if self._wait_operation(op.object['id']): return True return False + + def _to_key_pair(self, data): + key_pair = KeyPair(name=data['name'], + fingerprint=data['fingerprint'], + public_key=data.get('value', None), + private_key=data.get('privatekey', None), + driver=self, extra={'id': data['id']}) + return key_pair + + def _to_key_pairs(self, data): + return [self._to_key_pair(k) for k in data] + + def list_key_pairs(self): + """ + List registered key pairs. + + :return: A list of key par objects. + :rtype: ``list`` of :class:`libcloud.compute.base.KeyPair` + """ + kps = self.connection.request('hosting.ssh.list').object + return self._to_key_pairs(kps) + + def get_key_pair(self, name): + """ + Retrieve a single key pair. + + :param name: Name of the key pair to retrieve. + :type name: ``str`` + + :rtype: :class:`.KeyPair` + """ + filter_params = {'name': name} + kps = self.connection.request('hosting.ssh.list', filter_params).object + return self._to_key_pair(kps[0]) + + def import_key_pair_from_string(self, name, key_material): + """ + Create a new key pair object. + + :param name: Key pair name. + :type name: ``str`` + + :param key_material: Public key material. + :type key_material: ``str`` + + :return: Imported key pair object. + :rtype: :class:`.KeyPair` + """ + params = {'name': name, 'value': key_material} + kp = self.connection.request('hosting.ssh.create', params).object + return self._to_key_pair(kp) + + def delete_key_pair(self, key_pair): + """ + Delete an existing key pair. + + :param key_pair: Key pair object or ID. + :type key_pair: :class.KeyPair` or ``int`` + + :return: True of False based on success of Keypair deletion + :rtype: ``bool`` + """ + key_id = key_pair if isinstance(key_pair, int) \ + else key_pair.extra['id'] + success = self.connection.request('hosting.ssh.delete', key_id).object + return success diff --git a/libcloud/test/compute/fixtures/gandi/ssh_delete.xml b/libcloud/test/compute/fixtures/gandi/ssh_delete.xml new file mode 100644 index 0000000000..6dbecc1b31 --- /dev/null +++ b/libcloud/test/compute/fixtures/gandi/ssh_delete.xml @@ -0,0 +1,8 @@ + + + + +1 + + + diff --git a/libcloud/test/compute/fixtures/gandi/ssh_info.xml b/libcloud/test/compute/fixtures/gandi/ssh_info.xml new file mode 100644 index 0000000000..b48bace703 --- /dev/null +++ b/libcloud/test/compute/fixtures/gandi/ssh_info.xml @@ -0,0 +1,25 @@ + + + + + + +fingerprint +a6:1f:b8:b4:19:91:99:d8:af:ab:d6:17:72:8b:d1:6c + + +name +testkey + + +value +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCaCXFxl0cPZa+PkXSaux/9Sfn4J81eNJ4f/ZkjdIlmLJVYFUKbpC16eEwXYEfw/QBAZFPODCDQOFAZdgajO572y9scp09F7L7Rhwrw7DYu8STMIBz0XBIO8eOUyu5hVRpxaZGDih9B99e1hITTGFg+BveAmrdB8CPtygKo/fUmaamrocZBrD1betaLTC0i6/DVz7YAbR0CleZLlaBogqVhqmS0TB4J67aG2vvq1MjyOixQY5Ab4aXo4Dz1jd7oqCGCKCO9oKAG0ok94foxkfnCmfRrnfWzOA7SFWjUs65SOrGYZghspDcbJ9vA4ZkUuWJXPPvLVgsI8aHwkezJPD8Th root@testhost + + +id +10 + + + + + diff --git a/libcloud/test/compute/fixtures/gandi/ssh_list.xml b/libcloud/test/compute/fixtures/gandi/ssh_list.xml new file mode 100644 index 0000000000..003e54619f --- /dev/null +++ b/libcloud/test/compute/fixtures/gandi/ssh_list.xml @@ -0,0 +1,23 @@ + + + + + + + +fingerprint +a6:1f:b8:b4:19:91:99:d8:af:ab:d6:17:72:8b:d1:6c + + +id +10 + + +name +testkey + + + + + + diff --git a/libcloud/test/compute/test_gandi.py b/libcloud/test/compute/test_gandi.py index e4a11e8c56..0c00c15364 100644 --- a/libcloud/test/compute/test_gandi.py +++ b/libcloud/test/compute/test_gandi.py @@ -154,6 +154,31 @@ def test_ex_update_disk(self): disks = self.driver.list_volumes() self.assertTrue(self.driver.ex_update_disk(disks[0], new_size=4096)) + def test_list_key_pairs(self): + keys = self.driver.list_key_pairs() + self.assertTrue(len(keys) > 0) + + def test_get_key_pair(self): + key = self.driver.get_key_pair(10) + self.assertEqual(key.name, 'testkey') + + def test_import_key_pair_from_string(self): + key = self.driver.import_key_pair_from_string('testkey', '12345') + self.assertEqual(key.name, 'testkey') + self.assertEqual(key.extra['id'], 10) + + def test_delete_key_pair(self): + response = self.driver.delete_key_pair(10) + self.assertTrue(response) + + def test_ex_get_node(self): + node = self.driver.ex_get_node(34951) + self.assertEqual(node.name, "test2") + + def test_ex_get_volume(self): + volume = self.driver.ex_get_volume(1263) + self.assertEqual(volume.name, "libcloud") + class GandiRatingTests(unittest.TestCase): @@ -285,6 +310,22 @@ def _xmlrpc__hosting_disk_delete(self, method, url, body, headers): body = self.fixtures.load('disk_delete.xml') return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + def _xmlrpc__hosting_ssh_info(self, method, url, body, headers): + body = self.fixtures.load('ssh_info.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _xmlrpc__hosting_ssh_list(self, method, url, body, headers): + body = self.fixtures.load('ssh_list.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _xmlrpc__hosting_ssh_create(self, method, url, body, headers): + body = self.fixtures.load('ssh_info.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _xmlrpc__hosting_ssh_delete(self, method, url, body, headers): + body = self.fixtures.load('ssh_delete.xml') + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + class GandiMockRatingHttp(BaseGandiMockHttp):