Source code for libcloud.compute.drivers.digitalocean

# 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.
"""
DigitalOcean Driver
"""
import json
import warnings

from libcloud.utils.py3 import httplib
from libcloud.common.types import InvalidCredsError
from libcloud.compute.base import (
    Node,
    KeyPair,
    NodeSize,
    NodeImage,
    NodeDriver,
    NodeLocation,
    StorageVolume,
    VolumeSnapshot,
)
from libcloud.compute.types import Provider, NodeState
from libcloud.utils.iso8601 import parse_date
from libcloud.common.digitalocean import DigitalOcean_v1_Error, DigitalOcean_v2_BaseDriver

__all__ = ["DigitalOceanNodeDriver", "DigitalOcean_v2_NodeDriver"]


[docs]class DigitalOceanNodeDriver(NodeDriver): """ DigitalOcean NodeDriver defaulting to using APIv2. :keyword key: Personal Access Token required for authentication. :type key: ``str`` :keyword secret: Previously used with API version ``v1``. (deprecated) :type secret: ``str`` :keyword api_version: Specifies the API version to use. Defaults to using ``v2``, currently the only valid option. (optional) :type api_version: ``str`` """ type = Provider.DIGITAL_OCEAN name = "DigitalOcean" website = "https://www.digitalocean.com" def __new__(cls, key, secret=None, api_version="v2", **kwargs): if cls is DigitalOceanNodeDriver: if api_version == "v1" or secret is not None: if secret is not None and api_version == "v2": raise InvalidCredsError("secret not accepted for v2 authentication") raise DigitalOcean_v1_Error() elif api_version == "v2": cls = DigitalOcean_v2_NodeDriver else: raise NotImplementedError("Unsupported API version: %s" % (api_version)) return super().__new__(cls, **kwargs)
# TODO Implement v1 driver using KeyPair class SSHKey: def __init__(self, id, name, pub_key): self.id = id self.name = name self.pub_key = pub_key def __repr__(self): return ("<SSHKey: id=%s, name=%s, pub_key=%s>") % ( self.id, self.name, self.pub_key, )
[docs]class DigitalOcean_v2_NodeDriver(DigitalOcean_v2_BaseDriver, DigitalOceanNodeDriver): """ DigitalOcean NodeDriver using v2 of the API. """ NODE_STATE_MAP = { "new": NodeState.PENDING, "off": NodeState.STOPPED, "active": NodeState.RUNNING, "archive": NodeState.TERMINATED, } EX_CREATE_ATTRIBUTES = ["backups", "ipv6", "private_networking", "tags", "ssh_keys"]
[docs] def list_images(self): data = self._paginated_request("/v2/images", "images") return list(map(self._to_image, data))
[docs] def list_key_pairs(self): """ List all the available SSH keys. :return: Available SSH keys. :rtype: ``list`` of :class:`KeyPair` """ data = self._paginated_request("/v2/account/keys", "ssh_keys") return list(map(self._to_key_pair, data))
[docs] def list_locations(self, ex_available=True): """ List locations :param ex_available: Only return locations which are available. :type ex_evailable: ``bool`` """ locations = [] data = self._paginated_request("/v2/regions", "regions") for location in data: if ex_available: if location.get("available"): locations.append(self._to_location(location)) else: locations.append(self._to_location(location)) return locations
[docs] def list_nodes(self): data = self._paginated_request("/v2/droplets", "droplets") return list(map(self._to_node, data))
[docs] def list_sizes(self, location=None): data = self._paginated_request("/v2/sizes", "sizes") sizes = list(map(self._to_size, data)) if location: sizes = [size for size in sizes if location.id in size.extra.get("regions", [])] return sizes
[docs] def list_volumes(self): data = self._paginated_request("/v2/volumes", "volumes") return list(map(self._to_volume, data))
[docs] def create_node( self, name, size, image, location, ex_create_attr=None, ex_ssh_key_ids=None, ex_user_data=None, ): """ Create a node. The `ex_create_attr` parameter can include the following dictionary key and value pairs: * `backups`: ``bool`` defaults to False * `ipv6`: ``bool`` defaults to False * `private_networking`: ``bool`` defaults to False * `tags`: ``list`` of ``str`` tags * `user_data`: ``str`` for cloud-config data * `ssh_keys`: ``list`` of ``int`` key ids or ``str`` fingerprints `ex_create_attr['ssh_keys']` will override `ex_ssh_key_ids` assignment. :keyword ex_create_attr: A dictionary of optional attributes for droplet creation :type ex_create_attr: ``dict`` :keyword ex_ssh_key_ids: A list of ssh key ids which will be added to the server. (optional) :type ex_ssh_key_ids: ``list`` of ``int`` key ids or ``str`` key fingerprints :keyword ex_user_data: User data to be added to the node on create. (optional) :type ex_user_data: ``str`` :return: The newly created node. :rtype: :class:`Node` """ attr = { "name": name, "size": size.name, "image": image.id, "region": location.id, "user_data": ex_user_data, } if ex_ssh_key_ids: warnings.warn( "The ex_ssh_key_ids parameter has been deprecated in" " favor of the ex_create_attr parameter." ) attr["ssh_keys"] = ex_ssh_key_ids ex_create_attr = ex_create_attr or {} for key in ex_create_attr.keys(): if key in self.EX_CREATE_ATTRIBUTES: attr[key] = ex_create_attr[key] res = self.connection.request("/v2/droplets", data=json.dumps(attr), method="POST") data = res.object["droplet"] # TODO: Handle this in the response class status = res.object.get("status", "OK") if status == "ERROR": message = res.object.get("message", None) error_message = res.object.get("error_message", message) raise ValueError("Failed to create node: %s" % (error_message)) return self._to_node(data=data)
[docs] def destroy_node(self, node): res = self.connection.request("/v2/droplets/%s" % (node.id), method="DELETE") return res.status == httplib.NO_CONTENT
[docs] def reboot_node(self, node): attr = {"type": "reboot"} res = self.connection.request( "/v2/droplets/%s/actions" % (node.id), data=json.dumps(attr), method="POST" ) return res.status == httplib.CREATED
[docs] def create_image(self, node, name): """ Create an image from a Node. @inherits: :class:`NodeDriver.create_image` :param node: Node to use as base for image :type node: :class:`Node` :param node: Name for image :type node: ``str`` :rtype: ``bool`` """ attr = {"type": "snapshot", "name": name} res = self.connection.request( "/v2/droplets/%s/actions" % (node.id), data=json.dumps(attr), method="POST" ) return res.status == httplib.CREATED
[docs] def delete_image(self, image): """Delete an image for node. @inherits: :class:`NodeDriver.delete_image` :param image: the image to be deleted :type image: :class:`NodeImage` :rtype: ``bool`` """ res = self.connection.request("/v2/images/%s" % (image.id), method="DELETE") return res.status == httplib.NO_CONTENT
[docs] def get_image(self, image_id): """ Get an image based on an image_id @inherits: :class:`NodeDriver.get_image` :param image_id: Image identifier :type image_id: ``int`` :return: A NodeImage object :rtype: :class:`NodeImage` """ data = self._paginated_request("/v2/images/%s" % (image_id), "image") return self._to_image(data)
[docs] def ex_change_kernel(self, node, kernel_id): attr = {"type": "change_kernel", "kernel": kernel_id} res = self.connection.request( "/v2/droplets/%s/actions" % (node.id), data=json.dumps(attr), method="POST" ) return res.status == httplib.CREATED
[docs] def ex_enable_ipv6(self, node): attr = {"type": "enable_ipv6"} res = self.connection.request( "/v2/droplets/%s/actions" % (node.id), data=json.dumps(attr), method="POST" ) return res.status == httplib.CREATED
[docs] def ex_rename_node(self, node, name): attr = {"type": "rename", "name": name} res = self.connection.request( "/v2/droplets/%s/actions" % (node.id), data=json.dumps(attr), method="POST" ) return res.status == httplib.CREATED
[docs] def ex_shutdown_node(self, node): attr = {"type": "shutdown"} res = self.connection.request( "/v2/droplets/%s/actions" % (node.id), data=json.dumps(attr), method="POST" ) return res.status == httplib.CREATED
[docs] def ex_hard_reboot(self, node): attr = {"type": "power_cycle"} res = self.connection.request( "/v2/droplets/%s/actions" % (node.id), data=json.dumps(attr), method="POST" ) return res.status == httplib.CREATED
[docs] def ex_power_on_node(self, node): attr = {"type": "power_on"} res = self.connection.request( "/v2/droplets/%s/actions" % (node.id), data=json.dumps(attr), method="POST" ) return res.status == httplib.CREATED
[docs] def ex_rebuild_node(self, node): """ Destroy and rebuild the node using its base image. :param node: Node to rebuild :type node: :class:`Node` :return True if the operation began successfully :rtype ``bool`` """ attr = {"type": "rebuild", "image": node.extra["image"]["id"]} res = self.connection.request( "/v2/droplets/%s/actions" % (node.id), data=json.dumps(attr), method="POST" ) return res.status == httplib.CREATED
[docs] def ex_resize_node(self, node, size): """ Resize the node to a different machine size. Note that some resize operations are reversible, and others are irreversible. :param node: Node to rebuild :type node: :class:`Node` :param size: New size for this machine :type node: :class:`NodeSize` :return True if the operation began successfully :rtype ``bool`` """ attr = {"type": "resize", "size": size.name} res = self.connection.request( "/v2/droplets/%s/actions" % (node.id), data=json.dumps(attr), method="POST" ) return res.status == httplib.CREATED
[docs] def create_key_pair(self, name, public_key=""): """ Create a new SSH key. :param name: Key name (required) :type name: ``str`` :param public_key: Valid public key string (required) :type public_key: ``str`` """ attr = {"name": name, "public_key": public_key} res = self.connection.request("/v2/account/keys", method="POST", data=json.dumps(attr)) data = res.object["ssh_key"] return self._to_key_pair(data=data)
[docs] def delete_key_pair(self, key): """ Delete an existing SSH key. :param key: SSH key (required) :type key: :class:`KeyPair` """ key_id = key.extra["id"] res = self.connection.request("/v2/account/keys/%s" % (key_id), method="DELETE") return res.status == httplib.NO_CONTENT
[docs] 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` """ qkey = [k for k in self.list_key_pairs() if k.name == name][0] data = self.connection.request("/v2/account/keys/%s" % qkey.extra["id"]).object["ssh_key"] return self._to_key_pair(data=data)
[docs] def create_volume(self, size, name, location=None, snapshot=None): """ Create a new volume. :param size: Size of volume in gigabytes (required) :type size: ``int`` :param name: Name of the volume to be created :type name: ``str`` :param location: Which data center to create a volume in. If empty, undefined behavior will be selected. (optional) :type location: :class:`.NodeLocation` :param snapshot: Snapshot from which to create the new volume. (optional) :type snapshot: :class:`.VolumeSnapshot` :return: The newly created volume. :rtype: :class:`StorageVolume` """ attr = {"name": name, "size_gigabytes": size, "region": location.id} res = self.connection.request("/v2/volumes", data=json.dumps(attr), method="POST") data = res.object["volume"] status = res.object.get("status", "OK") if status == "ERROR": message = res.object.get("message", None) error_message = res.object.get("error_message", message) raise ValueError("Failed to create volume: %s" % (error_message)) return self._to_volume(data=data)
[docs] def destroy_volume(self, volume): """ Destroys a storage volume. :param volume: Volume to be destroyed :type volume: :class:`StorageVolume` :rtype: ``bool`` """ res = self.connection.request("/v2/volumes/%s" % volume.id, method="DELETE") return res.status == httplib.NO_CONTENT
[docs] def attach_volume(self, node, volume, device=None): """ Attaches volume to node. :param node: Node to attach volume to. :type node: :class:`.Node` :param volume: Volume to attach. :type volume: :class:`.StorageVolume` :param device: Where the device is exposed, e.g. '/dev/sdb' :type device: ``str`` :rytpe: ``bool`` """ attr = { "type": "attach", "droplet_id": node.id, "volume_name": volume.name, "region": volume.extra["region_slug"], } res = self.connection.request("/v2/volumes/actions", data=json.dumps(attr), method="POST") return res.status == httplib.ACCEPTED
[docs] def detach_volume(self, volume): """ Detaches a volume from a node. :param volume: Volume to be detached :type volume: :class:`.StorageVolume` :rtype: ``bool`` """ attr = { "type": "detach", "volume_name": volume.name, "region": volume.extra["region_slug"], } responses = [] for droplet_id in volume.extra["droplet_ids"]: attr["droplet_id"] = droplet_id res = self.connection.request( "/v2/volumes/actions", data=json.dumps(attr), method="POST" ) responses.append(res) return all([r.status == httplib.ACCEPTED for r in responses])
[docs] def create_volume_snapshot(self, volume, name): """ Create a new volume snapshot. :param volume: Volume to create a snapshot for :type volume: class:`StorageVolume` :return: The newly created volume snapshot. :rtype: :class:`VolumeSnapshot` """ attr = {"name": name} res = self.connection.request( "/v2/volumes/%s/snapshots" % (volume.id), data=json.dumps(attr), method="POST", ) data = res.object["snapshot"] return self._to_volume_snapshot(data=data)
[docs] def list_volume_snapshots(self, volume): """ List snapshots for a volume. :param volume: Volume to list snapshots for :type volume: class:`StorageVolume` :return: List of volume snapshots. :rtype: ``list`` of :class: `StorageVolume` """ data = self._paginated_request("/v2/volumes/%s/snapshots" % (volume.id), "snapshots") return list(map(self._to_volume_snapshot, data))
[docs] def delete_volume_snapshot(self, snapshot): """ Delete a volume snapshot :param snapshot: volume snapshot to delete :type snapshot: class:`VolumeSnapshot` :rtype: ``bool`` """ res = self.connection.request("v2/snapshots/%s" % (snapshot.id), method="DELETE") return res.status == httplib.NO_CONTENT
[docs] def ex_get_node_details(self, node_id): """ Lists details of the specified server. :param node_id: ID of the node which should be used :type node_id: ``str`` :rtype: :class:`Node` """ data = self._paginated_request("/v2/droplets/{}".format(node_id), "droplet") return self._to_node(data)
[docs] def ex_create_floating_ip(self, location): """ Create new floating IP reserved to a region. The newly created floating IP will not be associated to a Droplet. See https://developers.digitalocean.com/documentation/v2/#floating-ips :param location: Which data center to create the floating IP in. :type location: :class:`.NodeLocation` :rtype: :class:`DigitalOcean_v2_FloatingIpAddress` """ attr = {"region": location.id} resp = self.connection.request("/v2/floating_ips", data=json.dumps(attr), method="POST") return self._to_floating_ip(resp.object["floating_ip"])
[docs] def ex_delete_floating_ip(self, ip): """ Delete specified floating IP :param ip: floating IP to remove :type ip: :class:`DigitalOcean_v2_FloatingIpAddress` :rtype: ``bool`` """ resp = self.connection.request("/v2/floating_ips/{}".format(ip.id), method="DELETE") return resp.status == httplib.NO_CONTENT
[docs] def ex_list_floating_ips(self): """ List floating IPs :rtype: ``list`` of :class:`DigitalOcean_v2_FloatingIpAddress` """ return self._to_floating_ips(self._paginated_request("/v2/floating_ips", "floating_ips"))
[docs] def ex_get_floating_ip(self, ip): """ Get specified floating IP :param ip: floating IP to get :type ip: ``str`` :rtype: :class:`DigitalOcean_v2_FloatingIpAddress` """ floating_ips = self.ex_list_floating_ips() matching_ips = [x for x in floating_ips if x.ip_address == ip] if not matching_ips: raise ValueError("Floating ip %s not found" % ip) return matching_ips[0]
[docs] def ex_attach_floating_ip_to_node(self, node, ip): """ Attach the floating IP to the node :param node: node :type node: :class:`Node` :param ip: floating IP to attach :type ip: ``str`` or :class:`DigitalOcean_v2_FloatingIpAddress` :rtype: ``bool`` """ data = {"type": "assign", "droplet_id": node.id} resp = self.connection.request( "/v2/floating_ips/%s/actions" % ip.ip_address, data=json.dumps(data), method="POST", ) return resp.status == httplib.CREATED
[docs] def ex_detach_floating_ip_from_node(self, node, ip): """ Detach a floating IP from the given node Note: the 'node' object is not used in this method but it is added to the signature of ex_detach_floating_ip_from_node anyway so it conforms to the interface of the method of the same name for other drivers like for example OpenStack. :param node: Node from which the IP should be detached :type node: :class:`Node` :param ip: Floating IP to detach :type ip: :class:`DigitalOcean_v2_FloatingIpAddress` :rtype: ``bool`` """ data = {"type": "unassign"} resp = self.connection.request( "/v2/floating_ips/%s/actions" % ip.ip_address, data=json.dumps(data), method="POST", ) return resp.status == httplib.CREATED
def _to_node(self, data): extra_keys = [ "memory", "vcpus", "disk", "image", "size", "size_slug", "locked", "created_at", "networks", "kernel", "backup_ids", "snapshot_ids", "features", "tags", ] if "status" in data: state = self.NODE_STATE_MAP.get(data["status"], NodeState.UNKNOWN) else: state = NodeState.UNKNOWN created = parse_date(data["created_at"]) networks = data["networks"] private_ips = [] public_ips = [] if networks: for net in networks["v4"]: if net["type"] == "private": private_ips = [net["ip_address"]] if net["type"] == "public": public_ips = [net["ip_address"]] extra = {} for key in extra_keys: if key in data: extra[key] = data[key] extra["region"] = data.get("region", {}).get("name") # Untouched extra values, backwards compatibility resolve_data = data.get("image") if resolve_data: image = self._to_image(resolve_data) else: image = None resolve_data = extra.get("size") if resolve_data: size = self._to_size(resolve_data) else: size = None node = Node( id=data["id"], name=data["name"], state=state, image=image, size=size, public_ips=public_ips, private_ips=private_ips, created_at=created, driver=self, extra=extra, ) return node def _to_image(self, data): extra = { "distribution": data["distribution"], "public": data["public"], "slug": data["slug"], "regions": data["regions"], "min_disk_size": data["min_disk_size"], "created_at": data["created_at"], } return NodeImage(id=data["id"], name=data["name"], driver=self, extra=extra) def _to_volume(self, data): extra = { "created_at": data["created_at"], "droplet_ids": data["droplet_ids"], "region": data["region"], "region_slug": data["region"]["slug"], } return StorageVolume( id=data["id"], name=data["name"], size=data["size_gigabytes"], driver=self, extra=extra, ) def _to_location(self, data): extra = data.get("features", []) return NodeLocation( id=data["slug"], name=data["name"], country=None, extra=extra, driver=self ) def _to_size(self, data): extra = { "vcpus": data["vcpus"], "regions": data["regions"], "price_monthly": data["price_monthly"], } return NodeSize( id=data["slug"], name=data["slug"], ram=data["memory"], disk=data["disk"], bandwidth=data["transfer"], price=data["price_hourly"], driver=self, extra=extra, ) def _to_key_pair(self, data): extra = {"id": data["id"]} return KeyPair( name=data["name"], fingerprint=data["fingerprint"], public_key=data["public_key"], private_key=None, driver=self, extra=extra, ) def _to_volume_snapshot(self, data): extra = { "created_at": data["created_at"], "resource_id": data["resource_id"], "regions": data["regions"], "min_disk_size": data["min_disk_size"], } return VolumeSnapshot( id=data["id"], name=data["name"], size=data["size_gigabytes"], driver=self, extra=extra, ) def _to_floating_ips(self, obj): return [self._to_floating_ip(ip) for ip in obj] def _to_floating_ip(self, obj): return DigitalOcean_v2_FloatingIpAddress( # There is no ID, but the IP is unique so we can use that id=obj["ip"], ip_address=obj["ip"], node_id=obj["droplet"]["id"] if obj["droplet"] else None, extra={"region": obj["region"]}, driver=self, )
class DigitalOcean_v2_FloatingIpAddress: """ Floating IP info. """ def __init__(self, id, ip_address, node_id=None, extra=None, driver=None): self.id = str(id) self.ip_address = ip_address self.extra = extra self.node_id = node_id self.driver = driver def delete(self): """ Delete this floating IP :rtype: ``bool`` """ return self.driver.ex_delete_floating_ip(self) def __repr__(self): return "<DigitalOcean_v2_FloatingIpAddress: id=%s, ip_addr=%s," " driver=%s>" % ( self.id, self.ip_address, self.driver, )