| # Licensed to the Apache Software Foundation (ASF) under one or more |
| # contributor license agreements. See the NOTICE file distributed with |
| # this work for additional information regarding copyright ownership. |
| # The ASF licenses this file to You 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. |
| """ |
| Scaleway Driver |
| """ |
| |
| import copy |
| |
| from libcloud.utils.py3 import httplib |
| from libcloud.common.base import JsonResponse, ConnectionUserAndKey |
| from libcloud.common.types import ProviderError |
| from libcloud.compute.base import ( |
| Node, |
| KeyPair, |
| NodeSize, |
| NodeImage, |
| NodeDriver, |
| NodeLocation, |
| StorageVolume, |
| VolumeSnapshot, |
| ) |
| from libcloud.compute.types import NodeState, VolumeSnapshotState |
| from libcloud.utils.iso8601 import parse_date |
| from libcloud.compute.providers import Provider |
| |
| try: |
| import simplejson as json |
| except ImportError: |
| import json |
| |
| |
| __all__ = ["ScalewayResponse", "ScalewayConnection", "ScalewayNodeDriver"] |
| |
| SCALEWAY_API_HOSTS = { |
| "default": "cp-par1.scaleway.com", |
| "account": "account.scaleway.com", |
| "par1": "cp-par1.scaleway.com", |
| "ams1": "cp-ams1.scaleway.com", |
| } |
| |
| # The API doesn't give location info, so we provide it ourselves, instead. |
| SCALEWAY_LOCATION_DATA = [ |
| {"id": "par1", "name": "Paris 1", "country": "FR"}, |
| {"id": "ams1", "name": "Amsterdam 1", "country": "NL"}, |
| ] |
| |
| |
| class ScalewayResponse(JsonResponse): |
| valid_response_codes = [ |
| httplib.OK, |
| httplib.ACCEPTED, |
| httplib.CREATED, |
| httplib.NO_CONTENT, |
| ] |
| |
| def parse_error(self): |
| return super().parse_error()["message"] |
| |
| def success(self): |
| return self.status in self.valid_response_codes |
| |
| |
| class ScalewayConnection(ConnectionUserAndKey): |
| """ |
| Connection class for the Scaleway driver. |
| """ |
| |
| host = SCALEWAY_API_HOSTS["default"] |
| allow_insecure = False |
| responseCls = ScalewayResponse |
| |
| def request( |
| self, |
| action, |
| params=None, |
| data=None, |
| headers=None, |
| method="GET", |
| raw=False, |
| stream=False, |
| region=None, |
| ): |
| if region: |
| old_host = self.host |
| self.host = SCALEWAY_API_HOSTS[ |
| region.id if isinstance(region, NodeLocation) else region |
| ] |
| if not self.host == old_host: |
| self.connect() |
| |
| return super().request(action, params, data, headers, method, raw, stream) |
| |
| def _request_paged( |
| self, |
| action, |
| params=None, |
| data=None, |
| headers=None, |
| method="GET", |
| raw=False, |
| stream=False, |
| region=None, |
| ): |
| if params is None: |
| params = {} |
| |
| if isinstance(params, dict): |
| params["per_page"] = 100 |
| elif isinstance(params, list): |
| params.append(("per_page", 100)) # pylint: disable=no-member |
| |
| results = self.request(action, params, data, headers, method, raw, stream, region).object |
| |
| links = self.connection.getresponse().links |
| while links and "next" in links: |
| next = self.request( |
| links["next"]["url"], |
| data=data, |
| headers=headers, |
| method=method, |
| raw=raw, |
| stream=stream, |
| ).object |
| links = self.connection.getresponse().links |
| merged = {root: child + next[root] for root, child in list(results.items())} |
| results = merged |
| |
| return results |
| |
| def add_default_headers(self, headers): |
| """ |
| Add headers that are necessary for every request |
| """ |
| headers["X-Auth-Token"] = self.key |
| headers["Content-Type"] = "application/json" |
| return headers |
| |
| |
| def _to_lib_size(size): |
| return int(size / 1000 / 1000 / 1000) |
| |
| |
| def _to_api_size(size): |
| return int(size * 1000 * 1000 * 1000) |
| |
| |
| class ScalewayNodeDriver(NodeDriver): |
| """ |
| Scaleway Node Driver Class |
| |
| This is the primary driver for interacting with Scaleway. It contains all |
| of the standard libcloud methods that Scaleway's API supports. |
| """ |
| |
| type = Provider.SCALEWAY |
| connectionCls = ScalewayConnection |
| name = "Scaleway" |
| website = "https://www.scaleway.com/" |
| |
| SNAPSHOT_STATE_MAP = { |
| "snapshotting": VolumeSnapshotState.CREATING, |
| "available": VolumeSnapshotState.AVAILABLE, |
| "error": VolumeSnapshotState.ERROR, |
| } |
| |
| def list_locations(self): |
| """ |
| List data centers available. |
| |
| :return: list of node location objects |
| :rtype: ``list`` of :class:`.NodeLocation` |
| """ |
| return [ |
| NodeLocation(driver=self, **copy.deepcopy(location)) |
| for location in SCALEWAY_LOCATION_DATA |
| ] |
| |
| def list_sizes(self, region=None): |
| """ |
| List available VM sizes. |
| |
| :param region: The region in which to list sizes |
| (if None, use default region specified in __init__) |
| :type region: :class:`.NodeLocation` |
| |
| :return: list of node size objects |
| :rtype: ``list`` of :class:`.NodeSize` |
| """ |
| response = self.connection._request_paged("/products/servers", region=region) |
| sizes = response["servers"] |
| |
| response = self.connection._request_paged("/products/servers/availability", region=region) |
| availability = response["servers"] |
| |
| return sorted( |
| (self._to_size(name, sizes[name], availability[name]) for name in sizes), |
| key=lambda x: x.name, |
| ) |
| |
| def _to_size(self, name, size, availability): |
| min_disk = ( |
| _to_lib_size(size["volumes_constraint"]["min_size"] or 0) |
| if size["volumes_constraint"] |
| else 25 |
| ) |
| max_disk = ( |
| _to_lib_size(size["volumes_constraint"]["max_size"] or 0) |
| if size["volumes_constraint"] |
| else min_disk |
| ) |
| |
| extra = { |
| "cores": size["ncpus"], |
| "monthly": size["monthly_price"], |
| "arch": size["arch"], |
| "baremetal": size["baremetal"], |
| "availability": availability["availability"], |
| "max_disk": max_disk, |
| "internal_bandwidth": int( |
| (size["network"]["sum_internal_bandwidth"] or 0) / (1024 * 1024) |
| ), |
| "ipv6": size["network"]["ipv6_support"], |
| "alt_names": size["alt_names"], |
| } |
| |
| return NodeSize( |
| id=name, |
| name=name, |
| ram=int((size["ram"] or 0) / (1024 * 1024)), |
| disk=min_disk, |
| bandwidth=int((size["network"]["sum_internet_bandwidth"] or 0) / (1024 * 1024)), |
| price=size["hourly_price"], |
| driver=self, |
| extra=extra, |
| ) |
| |
| def list_images(self, region=None): |
| """ |
| List available VM images. |
| |
| :param region: The region in which to list images |
| (if None, use default region specified in __init__) |
| :type region: :class:`.NodeLocation` |
| |
| :return: list of image objects |
| :rtype: ``list`` of :class:`.NodeImage` |
| """ |
| response = self.connection._request_paged("/images", region=region) |
| images = response["images"] |
| return [self._to_image(image) for image in images] |
| |
| def create_image(self, node, name, region=None): |
| """ |
| Create a VM image from an existing node's root volume. |
| |
| :param node: The node from which to create the image |
| :type node: :class:`.Node` |
| |
| :param name: The name to give the image |
| :type name: ``str`` |
| |
| :param region: The region in which to create the image |
| (if None, use default region specified in __init__) |
| :type region: :class:`.NodeLocation` |
| |
| :return: the newly created image object |
| :rtype: :class:`.NodeImage` |
| """ |
| data = { |
| "organization": self.key, |
| "name": name, |
| "arch": node.extra["arch"], |
| "root_volume": node.extra["volumes"]["0"]["id"], |
| } |
| response = self.connection.request( |
| "/images", data=json.dumps(data), region=region, method="POST" |
| ) |
| image = response.object["image"] |
| return self._to_image(image) |
| |
| def delete_image(self, node_image, region=None): |
| """ |
| Delete a VM image. |
| |
| :param node_image: The image to delete |
| :type node_image: :class:`.NodeImage` |
| |
| :param region: The region in which to find/delete the image |
| (if None, use default region specified in __init__) |
| :type region: :class:`.NodeLocation` |
| |
| :return: True if the image was deleted, otherwise False |
| :rtype: ``bool`` |
| """ |
| return self.connection.request( |
| "/images/%s" % node_image.id, region=region, method="DELETE" |
| ).success() |
| |
| def get_image(self, image_id, region=None): |
| """ |
| Retrieve a specific VM image. |
| |
| :param image_id: The id of the image to retrieve |
| :type image_id: ``int`` |
| |
| :param region: The region in which to create the image |
| (if None, use default region specified in __init__) |
| :type region: :class:`.NodeLocation` |
| |
| :return: the requested image object |
| :rtype: :class:`.NodeImage` |
| """ |
| response = self.connection.request("/images/%s" % image_id, region=region) |
| image = response.object["image"] |
| return self._to_image(image) |
| |
| def _to_image(self, image): |
| extra = { |
| "arch": image["arch"], |
| "size": _to_lib_size(image.get("root_volume", {}).get("size", 0)) or 50, |
| "creation_date": parse_date(image["creation_date"]), |
| "modification_date": parse_date(image["modification_date"]), |
| "organization": image["organization"], |
| } |
| return NodeImage(id=image["id"], name=image["name"], driver=self, extra=extra) |
| |
| def list_nodes(self, region=None): |
| """ |
| List all nodes. |
| |
| :param region: The region in which to look for nodes |
| (if None, use default region specified in __init__) |
| :type region: :class:`.NodeLocation` |
| |
| :return: list of node objects |
| :rtype: ``list`` of :class:`.Node` |
| """ |
| response = self.connection._request_paged("/servers", region=region) |
| servers = response["servers"] |
| return [self._to_node(server) for server in servers] |
| |
| def _to_node(self, server): |
| public_ip = server["public_ip"] |
| private_ip = server["private_ip"] |
| location = server["location"] or {} |
| return Node( |
| id=server["id"], |
| name=server["name"], |
| state=NodeState.fromstring(server["state"]), |
| public_ips=[public_ip["address"]] if public_ip else [], |
| private_ips=[private_ip] if private_ip else [], |
| driver=self, |
| extra={ |
| "volumes": server["volumes"], |
| "tags": server["tags"], |
| "arch": server["arch"], |
| "organization": server["organization"], |
| "region": location.get("zone_id", "par1"), |
| }, |
| created_at=parse_date(server["creation_date"]), |
| ) |
| |
| def create_node(self, name, size, image, ex_volumes=None, ex_tags=None, region=None): |
| """ |
| Create a new node. |
| |
| :param name: The name to give the node |
| :type name: ``str`` |
| |
| :param size: The size of node to create |
| :type size: :class:`.NodeSize` |
| |
| :param image: The image to create the node with |
| :type image: :class:`.NodeImage` |
| |
| :param ex_volumes: Additional volumes to create the node with |
| :type ex_volumes: ``dict`` of :class:`.StorageVolume`s |
| |
| :param ex_tags: Tags to assign to the node |
| :type ex_tags: ``list`` of ``str`` |
| |
| :param region: The region in which to create the node |
| (if None, use default region specified in __init__) |
| :type region: :class:`.NodeLocation` |
| |
| :return: the newly created node object |
| :rtype: :class:`.Node` |
| """ |
| data = { |
| "name": name, |
| "organization": self.key, |
| "image": image.id, |
| "volumes": ex_volumes or {}, |
| "commercial_type": size.id, |
| "tags": ex_tags or [], |
| } |
| |
| allocate_space = image.extra.get("size", 50) |
| for volume in data["volumes"]: |
| allocate_space += _to_lib_size(volume["size"]) |
| |
| while allocate_space < size.disk: |
| if size.disk - allocate_space > 150: |
| bump = 150 |
| else: |
| bump = size.disk - allocate_space |
| |
| vol_num = len(data["volumes"]) + 1 |
| data["volumes"][str(vol_num)] = { |
| "name": "%s-%d" % (name, vol_num), |
| "organization": self.key, |
| "size": _to_api_size(bump), |
| "volume_type": "l_ssd", |
| } |
| allocate_space += bump |
| |
| if allocate_space > size.extra.get("max_disk", size.disk): |
| range = ( |
| "of %dGB" % size.disk |
| if size.extra.get("max_disk", size.disk) == size.disk |
| else "between %dGB and %dGB" % (size.extra.get("max_disk", size.disk), size.disk) |
| ) |
| raise ProviderError( |
| value=( |
| "%s only supports a total volume size %s; tried %dGB" |
| % (size.id, range, allocate_space) |
| ), |
| http_code=400, |
| driver=self, |
| ) |
| |
| response = self.connection.request( |
| "/servers", data=json.dumps(data), region=region, method="POST" |
| ) |
| server = response.object["server"] |
| node = self._to_node(server) |
| node.extra["region"] = (region.id if isinstance(region, NodeLocation) else region) or "par1" |
| |
| # Scaleway doesn't start servers by default, let's do it |
| self._action(node.id, "poweron") |
| |
| return node |
| |
| def _action(self, server_id, action, region=None): |
| return self.connection.request( |
| "/servers/%s/action" % server_id, |
| region=region, |
| data=json.dumps({"action": action}), |
| method="POST", |
| ).success() |
| |
| def reboot_node(self, node): |
| """ |
| Reboot a node. |
| |
| :param node: The node to be rebooted |
| :type node: :class:`Node` |
| |
| :return: True if the reboot was successful, otherwise False |
| :rtype: ``bool`` |
| """ |
| return self._action(node.id, "reboot") |
| |
| def destroy_node(self, node): |
| """ |
| Destroy a node. |
| |
| :param node: The node to be destroyed |
| :type node: :class:`Node` |
| |
| :return: True if the destroy was successful, otherwise False |
| :rtype: ``bool`` |
| """ |
| return self._action(node.id, "terminate") |
| |
| def list_volumes(self, region=None): |
| """ |
| Return a list of volumes. |
| |
| :param region: The region in which to look for volumes |
| (if None, use default region specified in __init__) |
| :type region: :class:`.NodeLocation` |
| |
| :return: A list of volume objects. |
| :rtype: ``list`` of :class:`StorageVolume` |
| """ |
| response = self.connection._request_paged("/volumes", region=region) |
| volumes = response["volumes"] |
| return [self._to_volume(volume) for volume in volumes] |
| |
| def _to_volume(self, volume): |
| extra = { |
| "organization": volume["organization"], |
| "volume_type": volume["volume_type"], |
| "creation_date": parse_date(volume["creation_date"]), |
| "modification_date": parse_date(volume["modification_date"]), |
| } |
| return StorageVolume( |
| id=volume["id"], |
| name=volume["name"], |
| size=_to_lib_size(volume["size"]), |
| driver=self, |
| extra=extra, |
| ) |
| |
| def list_volume_snapshots(self, volume, region=None): |
| """ |
| List snapshots for a storage volume. |
| |
| @inherits :class:`NodeDriver.list_volume_snapshots` |
| |
| :param region: The region in which to look for snapshots |
| (if None, use default region specified in __init__) |
| :type region: :class:`.NodeLocation` |
| """ |
| response = self.connection._request_paged("/snapshots", region=region) |
| snapshots = filter(lambda s: s["base_volume"]["id"] == volume.id, response["snapshots"]) |
| return [self._to_snapshot(snapshot) for snapshot in snapshots] |
| |
| def _to_snapshot(self, snapshot): |
| state = self.SNAPSHOT_STATE_MAP.get(snapshot["state"], VolumeSnapshotState.UNKNOWN) |
| extra = { |
| "organization": snapshot["organization"], |
| "volume_type": snapshot["volume_type"], |
| } |
| return VolumeSnapshot( |
| id=snapshot["id"], |
| driver=self, |
| size=_to_lib_size(snapshot["size"]), |
| created=parse_date(snapshot["creation_date"]), |
| state=state, |
| extra=extra, |
| ) |
| |
| def create_volume(self, size, name, region=None): |
| """ |
| Create a new volume. |
| |
| :param size: Size of volume in gigabytes. |
| :type size: ``int`` |
| |
| :param name: Name of the volume to be created. |
| :type name: ``str`` |
| |
| :param region: The region in which to create the volume |
| (if None, use default region specified in __init__) |
| :type region: :class:`.NodeLocation` |
| |
| :return: The newly created volume. |
| :rtype: :class:`StorageVolume` |
| """ |
| data = { |
| "name": name, |
| "organization": self.key, |
| "volume_type": "l_ssd", |
| "size": _to_api_size(size), |
| } |
| response = self.connection.request( |
| "/volumes", region=region, data=json.dumps(data), method="POST" |
| ) |
| volume = response.object["volume"] |
| return self._to_volume(volume) |
| |
| def create_volume_snapshot(self, volume, name, region=None): |
| """ |
| Create snapshot from volume. |
| |
| :param volume: The volume to create a snapshot from |
| :type volume: :class`StorageVolume` |
| |
| :param name: The name to give the snapshot |
| :type name: ``str`` |
| |
| :param region: The region in which to create the snapshot |
| (if None, use default region specified in __init__) |
| :type region: :class:`.NodeLocation` |
| |
| :return: The newly created snapshot. |
| :rtype: :class:`VolumeSnapshot` |
| """ |
| data = {"name": name, "organization": self.key, "volume_id": volume.id} |
| response = self.connection.request( |
| "/snapshots", region=region, data=json.dumps(data), method="POST" |
| ) |
| snapshot = response.object["snapshot"] |
| return self._to_snapshot(snapshot) |
| |
| def destroy_volume(self, volume, region=None): |
| """ |
| Destroys a storage volume. |
| |
| :param volume: Volume to be destroyed |
| :type volume: :class:`StorageVolume` |
| |
| :param region: The region in which to look for the volume |
| (if None, use default region specified in __init__) |
| :type region: :class:`.NodeLocation` |
| |
| :return: True if the destroy was successful, otherwise False |
| :rtype: ``bool`` |
| """ |
| return self.connection.request( |
| "/volumes/%s" % volume.id, region=region, method="DELETE" |
| ).success() |
| |
| def destroy_volume_snapshot(self, snapshot, region=None): |
| """ |
| Dostroy a volume snapshot |
| |
| :param snapshot: volume snapshot to destroy |
| :type snapshot: class:`VolumeSnapshot` |
| |
| :param region: The region in which to look for the snapshot |
| (if None, use default region specified in __init__) |
| :type region: :class:`.NodeLocation` |
| |
| :return: True if the destroy was successful, otherwise False |
| :rtype: ``bool`` |
| """ |
| return self.connection.request( |
| "/snapshots/%s" % snapshot.id, region=region, method="DELETE" |
| ).success() |
| |
| def list_key_pairs(self): |
| """ |
| List all the available SSH keys. |
| |
| :return: Available SSH keys. |
| :rtype: ``list`` of :class:`KeyPair` |
| """ |
| response = self.connection.request("/users/%s" % (self._get_user_id()), region="account") |
| keys = response.object["user"]["ssh_public_keys"] |
| return [ |
| KeyPair( |
| name=" ".join(key["key"].split(" ")[2:]), |
| public_key=" ".join(key["key"].split(" ")[:2]), |
| fingerprint=key["fingerprint"], |
| driver=self, |
| ) |
| for key in keys |
| ] |
| |
| def import_key_pair_from_string(self, name, key_material): |
| """ |
| Import a new public key from string. |
| |
| :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` |
| """ |
| new_key = KeyPair( |
| name=name, |
| public_key=" ".join(key_material.split(" ")[:2]), |
| fingerprint=None, |
| driver=self, |
| ) |
| keys = [key for key in self.list_key_pairs() if not key.name == name] |
| keys.append(new_key) |
| return self._save_keys(keys) |
| |
| def delete_key_pair(self, key_pair): |
| """ |
| Delete an existing key pair. |
| |
| :param key_pair: Key pair object. |
| :type key_pair: :class:`KeyPair` |
| |
| :return: True of False based on success of Keypair deletion |
| :rtype: ``bool`` |
| """ |
| keys = [key for key in self.list_key_pairs() if not key.name == key_pair.name] |
| return self._save_keys(keys) |
| |
| def _get_user_id(self): |
| response = self.connection.request("/tokens/%s" % self.secret, region="account") |
| return response.object["token"]["user_id"] |
| |
| def _save_keys(self, keys): |
| data = { |
| "ssh_public_keys": [{"key": "{} {}".format(key.public_key, key.name)} for key in keys] |
| } |
| response = self.connection.request( |
| "/users/%s" % (self._get_user_id()), |
| region="account", |
| method="PATCH", |
| data=json.dumps(data), |
| ) |
| return response.success() |