blob: 2df7000acaef00d4c7943a22a486dae90a62cf36 [file] [log] [blame]
# 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.
"""libcloud driver for the Linode(R) API
This driver implements all libcloud functionality for the Linode API.
Since the API is a bit more fine-grained, create_node abstracts a significant
amount of work (and may take a while to run).
Linode home page http://www.linode.com/
Linode API documentation http://www.linode.com/api/
Alternate bindings for reference http://github.com/tjfontaine/linode-python
Linode(R) is a registered trademark of Linode, LLC.
"""
import os
import re
import binascii
import itertools
from copy import copy
from datetime import datetime
from libcloud.utils.py3 import httplib
from libcloud.compute.base import (
Node,
NodeSize,
NodeImage,
NodeDriver,
NodeLocation,
StorageVolume,
NodeAuthSSHKey,
NodeAuthPassword,
)
from libcloud.common.linode import (
API_ROOT,
LINODE_PLAN_IDS,
DEFAULT_API_VERSION,
LINODE_DISK_FILESYSTEMS,
LINODE_DISK_FILESYSTEMS_V4,
LinodeDisk,
LinodeException,
LinodeIPAddress,
LinodeConnection,
LinodeExceptionV4,
LinodeConnectionV4,
)
from libcloud.compute.types import Provider, NodeState, StorageVolumeState
from libcloud.utils.networking import is_private_subnet
try:
import simplejson as json
except ImportError:
import json
class LinodeNodeDriver(NodeDriver):
name = "Linode"
website = "http://www.linode.com/"
type = Provider.LINODE
def __new__(
cls,
key,
secret=None,
secure=True,
host=None,
port=None,
api_version=DEFAULT_API_VERSION,
region=None,
**kwargs,
):
if cls is LinodeNodeDriver:
if api_version == "3.0":
cls = LinodeNodeDriverV3
elif api_version == "4.0":
cls = LinodeNodeDriverV4
else:
raise NotImplementedError(
"No Linode driver found for API version: %s" % (api_version)
)
return super().__new__(cls)
class LinodeNodeDriverV3(LinodeNodeDriver):
"""libcloud driver for the Linode 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:
http://www.linode.com/api/
"""
connectionCls = LinodeConnection
_linode_plan_ids = LINODE_PLAN_IDS
_linode_disk_filesystems = LINODE_DISK_FILESYSTEMS
features = {"create_node": ["ssh_key", "password"]}
def __init__(
self,
key,
secret=None,
secure=True,
host=None,
port=None,
api_version=None,
region=None,
**kwargs,
):
"""Instantiate the driver with the given API key
:param key: the API key to use (required)
:type key: ``str``
:rtype: ``None``
"""
self.datacenter = None
NodeDriver.__init__(self, key)
# Converts Linode's state from DB to a NodeState constant.
LINODE_STATES = {
(-2): NodeState.UNKNOWN, # Boot Failed
(-1): NodeState.PENDING, # Being Created
0: NodeState.PENDING, # Brand New
1: NodeState.RUNNING, # Running
2: NodeState.STOPPED, # Powered Off
3: NodeState.REBOOTING, # Shutting Down
4: NodeState.UNKNOWN, # Reserved
}
def list_nodes(self):
"""
List all Linodes that the API key can access
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.
:return: List of node objects that the API key can access
:rtype: ``list`` of :class:`Node`
"""
params = {"api_action": "linode.list"}
data = self.connection.request(API_ROOT, params=params).objects[0]
return self._to_nodes(data)
def start_node(self, node):
"""
Boot the given Linode
"""
params = {"api_action": "linode.boot", "LinodeID": node.id}
self.connection.request(API_ROOT, params=params)
return True
def stop_node(self, node):
"""
Shutdown the given Linode
"""
params = {"api_action": "linode.shutdown", "LinodeID": node.id}
self.connection.request(API_ROOT, params=params)
return True
def reboot_node(self, node):
"""
Reboot the given Linode
Will issue a shutdown job followed by a boot job, using the last booted
configuration. In most cases, this will be the only configuration.
:param node: the Linode to reboot
:type node: :class:`Node`
:rtype: ``bool``
"""
params = {"api_action": "linode.reboot", "LinodeID": node.id}
self.connection.request(API_ROOT, params=params)
return True
def destroy_node(self, node):
"""Destroy the given Linode
Will remove the Linode from the account and issue a prorated credit. A
grant for removing Linodes from the account is required, otherwise this
method will fail.
In most cases, all disk images must be removed from a Linode before the
Linode can be removed; however, this call explicitly skips those
safeguards. There is no going back from this method.
:param node: the Linode to destroy
:type node: :class:`Node`
:rtype: ``bool``
"""
params = {
"api_action": "linode.delete",
"LinodeID": node.id,
"skipChecks": True,
}
self.connection.request(API_ROOT, params=params)
return True
def create_node(
self,
name,
image,
size,
auth,
location=None,
ex_swap=None,
ex_rsize=None,
ex_kernel=None,
ex_payment=None,
ex_comment=None,
ex_private=False,
lconfig=None,
lroot=None,
lswap=None,
):
"""Create a new Linode, deploy a Linux distribution, and boot
This call abstracts much of the functionality of provisioning a Linode
and getting it booted. A global grant to add Linodes to the account is
required, as this call will result in a billing charge.
Note that there is a safety valve of 5 Linodes per hour, in order to
prevent a runaway script from ruining your day.
:keyword name: the name to assign the Linode (mandatory)
:type name: ``str``
:keyword image: which distribution to deploy on the Linode (mandatory)
:type image: :class:`NodeImage`
:keyword size: the plan size to create (mandatory)
:type size: :class:`NodeSize`
:keyword auth: an SSH key or root password (mandatory)
:type auth: :class:`NodeAuthSSHKey` or :class:`NodeAuthPassword`
:keyword location: which datacenter to create the Linode in
:type location: :class:`NodeLocation`
:keyword ex_swap: size of the swap partition in MB (128)
:type ex_swap: ``int``
:keyword ex_rsize: size of the root partition in MB (plan size - swap).
:type ex_rsize: ``int``
:keyword ex_kernel: a kernel ID from avail.kernels (Latest 2.6 Stable).
:type ex_kernel: ``str``
:keyword ex_payment: one of 1, 12, or 24; subscription length (1)
:type ex_payment: ``int``
:keyword ex_comment: a small comment for the configuration (libcloud)
:type ex_comment: ``str``
:keyword ex_private: whether or not to request a private IP (False)
:type ex_private: ``bool``
:keyword lconfig: what to call the configuration (generated)
:type lconfig: ``str``
:keyword lroot: what to call the root image (generated)
:type lroot: ``str``
:keyword lswap: what to call the swap space (generated)
:type lswap: ``str``
:return: Node representing the newly-created Linode
:rtype: :class:`Node`
"""
auth = self._get_and_check_auth(auth)
# Pick a location (resolves LIBCLOUD-41 in JIRA)
if location:
chosen = location.id
elif self.datacenter:
chosen = self.datacenter
else:
raise LinodeException(0xFB, "Need to select a datacenter first")
# Step 0: Parameter validation before we purchase
# We're especially careful here so we don't fail after purchase, rather
# than getting halfway through the process and having the API fail.
# Plan ID
plans = self.list_sizes()
if size.id not in [p.id for p in plans]:
raise LinodeException(0xFB, "Invalid plan ID -- avail.plans")
# Payment schedule
payment = "1" if not ex_payment else str(ex_payment)
if payment not in ["1", "12", "24"]:
raise LinodeException(0xFB, "Invalid subscription (1, 12, 24)")
ssh = None
root = None
# SSH key and/or root password
if isinstance(auth, NodeAuthSSHKey):
ssh = auth.pubkey # pylint: disable=no-member
elif isinstance(auth, NodeAuthPassword):
root = auth.password
if not ssh and not root:
raise LinodeException(0xFB, "Need SSH key or root password")
if root is not None and len(root) < 6:
raise LinodeException(0xFB, "Root password is too short")
# Swap size
try:
swap = 128 if not ex_swap else int(ex_swap)
except Exception:
raise LinodeException(0xFB, "Need an integer swap size")
# Root partition size
imagesize = (size.disk - swap) if not ex_rsize else int(ex_rsize)
if (imagesize + swap) > size.disk:
raise LinodeException(0xFB, "Total disk images are too big")
# Distribution ID
distros = self.list_images()
if image.id not in [d.id for d in distros]:
raise LinodeException(0xFB, "Invalid distro -- avail.distributions")
# Kernel
if ex_kernel:
kernel = ex_kernel
else:
if image.extra["64bit"]:
# For a list of available kernel ids, see
# https://www.linode.com/kernels/
kernel = 138
else:
kernel = 137
params = {"api_action": "avail.kernels"}
kernels = self.connection.request(API_ROOT, params=params).objects[0]
if kernel not in [z["KERNELID"] for z in kernels]:
raise LinodeException(0xFB, "Invalid kernel -- avail.kernels")
# Comments
comments = (
"Created by Apache libcloud <https://www.libcloud.org>"
if not ex_comment
else ex_comment
)
# Step 1: linode.create
params = {
"api_action": "linode.create",
"DatacenterID": chosen,
"PlanID": size.id,
"PaymentTerm": payment,
}
data = self.connection.request(API_ROOT, params=params).objects[0]
linode = {"id": data["LinodeID"]}
# Step 1b. linode.update to rename the Linode
params = {
"api_action": "linode.update",
"LinodeID": linode["id"],
"Label": name,
}
self.connection.request(API_ROOT, params=params)
# Step 1c. linode.ip.addprivate if it was requested
if ex_private:
params = {"api_action": "linode.ip.addprivate", "LinodeID": linode["id"]}
self.connection.request(API_ROOT, params=params)
# Step 1d. Labels
# use the linode id as the name can be up to 63 chars and the labels
# are limited to 48 chars
label = {
"lconfig": "[%s] Configuration Profile" % linode["id"],
"lroot": "[{}] {} Disk Image".format(linode["id"], image.name),
"lswap": "[%s] Swap Space" % linode["id"],
}
if lconfig:
label["lconfig"] = lconfig
if lroot:
label["lroot"] = lroot
if lswap:
label["lswap"] = lswap
# Step 2: linode.disk.createfromdistribution
if not root:
root = binascii.b2a_base64(os.urandom(8)).decode("ascii").strip()
params = {
"api_action": "linode.disk.createfromdistribution",
"LinodeID": linode["id"],
"DistributionID": image.id,
"Label": label["lroot"],
"Size": imagesize,
"rootPass": root,
}
if ssh:
params["rootSSHKey"] = ssh
data = self.connection.request(API_ROOT, params=params).objects[0]
linode["rootimage"] = data["DiskID"]
# Step 3: linode.disk.create for swap
params = {
"api_action": "linode.disk.create",
"LinodeID": linode["id"],
"Label": label["lswap"],
"Type": "swap",
"Size": swap,
}
data = self.connection.request(API_ROOT, params=params).objects[0]
linode["swapimage"] = data["DiskID"]
# Step 4: linode.config.create for main profile
disks = "{},{},,,,,,,".format(linode["rootimage"], linode["swapimage"])
params = {
"api_action": "linode.config.create",
"LinodeID": linode["id"],
"KernelID": kernel,
"Label": label["lconfig"],
"Comments": comments,
"DiskList": disks,
}
if ex_private:
params["helper_network"] = True
params["helper_distro"] = True
data = self.connection.request(API_ROOT, params=params).objects[0]
linode["config"] = data["ConfigID"]
# Step 5: linode.boot
params = {
"api_action": "linode.boot",
"LinodeID": linode["id"],
"ConfigID": linode["config"],
}
self.connection.request(API_ROOT, params=params)
# Make a node out of it and hand it back
params = {"api_action": "linode.list", "LinodeID": linode["id"]}
data = self.connection.request(API_ROOT, params=params).objects[0]
nodes = self._to_nodes(data)
if len(nodes) == 1:
node = nodes[0]
if getattr(auth, "generated", False):
node.extra["password"] = auth.password
return node
return None
def ex_resize_node(self, node, size):
"""Resizes a Linode from one plan to another
Immediately shuts the Linode down, charges/credits the account,
and issue a migration to another host server.
Requires a size (numeric), which is the desired PlanID available from
avail.LinodePlans()
After resize is complete the node needs to be booted
"""
params = {"api_action": "linode.resize", "LinodeID": node.id, "PlanID": size}
self.connection.request(API_ROOT, params=params)
return True
def ex_start_node(self, node):
# NOTE: This method is here for backward compatibility reasons after
# this method was promoted to be part of the standard compute API in
# Libcloud v2.7.0
return self.start_node(node=node)
def ex_stop_node(self, node):
# NOTE: This method is here for backward compatibility reasons after
# this method was promoted to be part of the standard compute API in
# Libcloud v2.7.0
return self.stop_node(node=node)
def ex_rename_node(self, node, name):
"""Renames a node"""
params = {"api_action": "linode.update", "LinodeID": node.id, "Label": name}
self.connection.request(API_ROOT, params=params)
return True
def list_sizes(self, location=None):
"""
List available Linode plans
Gets the sizes that can be used for creating a Linode. Since available
Linode plans vary per-location, this method can also be passed a
location to filter the availability.
:keyword location: the facility to retrieve plans in
:type location: :class:`NodeLocation`
:rtype: ``list`` of :class:`NodeSize`
"""
params = {"api_action": "avail.linodeplans"}
data = self.connection.request(API_ROOT, params=params).objects[0]
sizes = []
for obj in data:
n = NodeSize(
id=obj["PLANID"],
name=obj["LABEL"],
ram=obj["RAM"],
disk=(obj["DISK"] * 1024),
bandwidth=obj["XFER"],
price=obj["PRICE"],
driver=self.connection.driver,
)
sizes.append(n)
return sizes
def list_images(self):
"""
List available Linux distributions
Retrieve all Linux distributions that can be deployed to a Linode.
:rtype: ``list`` of :class:`NodeImage`
"""
params = {"api_action": "avail.distributions"}
data = self.connection.request(API_ROOT, params=params).objects[0]
distros = []
for obj in data:
i = NodeImage(
id=obj["DISTRIBUTIONID"],
name=obj["LABEL"],
driver=self.connection.driver,
extra={"pvops": obj["REQUIRESPVOPSKERNEL"], "64bit": obj["IS64BIT"]},
)
distros.append(i)
return distros
def list_locations(self):
"""
List available facilities for deployment
Retrieve all facilities that a Linode can be deployed in.
:rtype: ``list`` of :class:`NodeLocation`
"""
params = {"api_action": "avail.datacenters"}
data = self.connection.request(API_ROOT, params=params).objects[0]
nl = []
for dc in data:
country = None
if "USA" in dc["LOCATION"]:
country = "US"
elif "UK" in dc["LOCATION"]:
country = "GB"
elif "JP" in dc["LOCATION"]:
country = "JP"
else:
country = "??"
nl.append(NodeLocation(dc["DATACENTERID"], dc["LOCATION"], country, self))
return nl
def linode_set_datacenter(self, dc):
"""
Set the default datacenter for Linode creation
Since Linodes must be created in a facility, this function sets the
default that :class:`create_node` will use. If a location keyword is
not passed to :class:`create_node`, this method must have already been
used.
:keyword dc: the datacenter to create Linodes in unless specified
:type dc: :class:`NodeLocation`
:rtype: ``bool``
"""
did = dc.id
params = {"api_action": "avail.datacenters"}
data = self.connection.request(API_ROOT, params=params).objects[0]
for datacenter in data:
if did == dc["DATACENTERID"]:
self.datacenter = did
return
dcs = ", ".join([d["DATACENTERID"] for d in data])
self.datacenter = None
raise LinodeException(0xFD, "Invalid datacenter (use one of %s)" % dcs)
def destroy_volume(self, volume):
"""
Destroys disk volume for the Linode. Linode id is to be provided as
extra["LinodeId"] whithin :class:`StorageVolume`. It can be retrieved
by :meth:`libcloud.compute.drivers.linode.LinodeNodeDriver\
.ex_list_volumes`.
:param volume: Volume to be destroyed
:type volume: :class:`StorageVolume`
:rtype: ``bool``
"""
if not isinstance(volume, StorageVolume):
raise LinodeException(0xFD, "Invalid volume instance")
if volume.extra["LINODEID"] is None:
raise LinodeException(0xFD, "Missing LinodeID")
params = {
"api_action": "linode.disk.delete",
"LinodeID": volume.extra["LINODEID"],
"DiskID": volume.id,
}
self.connection.request(API_ROOT, params=params)
return True
def ex_create_volume(self, size, name, node, fs_type):
"""
Create disk for the Linode.
:keyword size: Size of volume in megabytes (required)
:type size: ``int``
:keyword name: Name of the volume to be created
:type name: ``str``
:keyword node: Node to attach volume to.
:type node: :class:`Node`
:keyword fs_type: The formatted type of this disk. Valid types are:
ext3, ext4, swap, raw
:type fs_type: ``str``
:return: StorageVolume representing the newly-created volume
:rtype: :class:`StorageVolume`
"""
# check node
if not isinstance(node, Node):
raise LinodeException(0xFD, "Invalid node instance")
# check space available
total_space = node.extra["TOTALHD"]
existing_volumes = self.ex_list_volumes(node)
used_space = 0
for volume in existing_volumes:
used_space = used_space + volume.size
available_space = total_space - used_space
if available_space < size:
raise LinodeException(
0xFD,
"Volume size too big. Available space\
%d"
% available_space,
)
# check filesystem type
if fs_type not in self._linode_disk_filesystems:
raise LinodeException(0xFD, "Not valid filesystem type")
params = {
"api_action": "linode.disk.create",
"LinodeID": node.id,
"Label": name,
"Type": fs_type,
"Size": size,
}
data = self.connection.request(API_ROOT, params=params).objects[0]
volume = data["DiskID"]
# Make a volume out of it and hand it back
params = {
"api_action": "linode.disk.list",
"LinodeID": node.id,
"DiskID": volume,
}
data = self.connection.request(API_ROOT, params=params).objects[0]
return self._to_volumes(data)[0]
def ex_list_volumes(self, node, disk_id=None):
"""
List existing disk volumes for for given Linode.
:keyword node: Node to list disk volumes for. (required)
:type node: :class:`Node`
:keyword disk_id: Id for specific disk volume. (optional)
:type disk_id: ``int``
:rtype: ``list`` of :class:`StorageVolume`
"""
if not isinstance(node, Node):
raise LinodeException(0xFD, "Invalid node instance")
params = {"api_action": "linode.disk.list", "LinodeID": node.id}
# Add param if disk_id was specified
if disk_id is not None:
params["DiskID"] = disk_id
data = self.connection.request(API_ROOT, params=params).objects[0]
return self._to_volumes(data)
def _to_volumes(self, objs):
"""
Covert returned JSON volumes into StorageVolume instances
:keyword objs: ``list`` of JSON dictionaries representing the
StorageVolumes
:type objs: ``list``
:return: ``list`` of :class:`StorageVolume`s
"""
volumes = {}
for o in objs:
vid = o["DISKID"]
volumes[vid] = vol = StorageVolume(
id=vid,
name=o["LABEL"],
size=int(o["SIZE"]),
driver=self.connection.driver,
)
vol.extra = copy(o)
return list(volumes.values())
def _to_nodes(self, objs):
"""Convert returned JSON Linodes into Node instances
:keyword objs: ``list`` of JSON dictionaries representing the Linodes
:type objs: ``list``
:return: ``list`` of :class:`Node`s"""
# Get the IP addresses for the Linodes
nodes = {}
batch = []
for o in objs:
lid = o["LINODEID"]
nodes[lid] = n = Node(
id=lid,
name=o["LABEL"],
public_ips=[],
private_ips=[],
state=self.LINODE_STATES[o["STATUS"]],
driver=self.connection.driver,
)
n.extra = copy(o)
n.extra["PLANID"] = self._linode_plan_ids.get(o.get("TOTALRAM"))
batch.append({"api_action": "linode.ip.list", "LinodeID": lid})
# Avoid batch limitation
ip_answers = []
args = [iter(batch)] * 25
for twenty_five in itertools.zip_longest(*args):
twenty_five = [q for q in twenty_five if q]
params = {
"api_action": "batch",
"api_requestArray": json.dumps(twenty_five),
}
req = self.connection.request(API_ROOT, params=params)
if not req.success() or len(req.objects) == 0:
return None
ip_answers.extend(req.objects)
# Add the returned IPs to the nodes and return them
for ip_list in ip_answers:
for ip in ip_list:
lid = ip["LINODEID"]
which = nodes[lid].public_ips if ip["ISPUBLIC"] == 1 else nodes[lid].private_ips
which.append(ip["IPADDRESS"])
return list(nodes.values())
class LinodeNodeDriverV4(LinodeNodeDriver):
connectionCls = LinodeConnectionV4
_linode_disk_filesystems = LINODE_DISK_FILESYSTEMS_V4
LINODE_STATES = {
"running": NodeState.RUNNING,
"stopped": NodeState.STOPPED,
"provisioning": NodeState.STARTING,
"offline": NodeState.STOPPED,
"booting": NodeState.STARTING,
"rebooting": NodeState.REBOOTING,
"shutting_down": NodeState.STOPPING,
"deleting": NodeState.PENDING,
"migrating": NodeState.MIGRATING,
"rebuilding": NodeState.UPDATING,
"cloning": NodeState.MIGRATING,
"restoring": NodeState.PENDING,
"resizing": NodeState.RECONFIGURING,
}
LINODE_DISK_STATES = {
"ready": StorageVolumeState.AVAILABLE,
"not ready": StorageVolumeState.CREATING,
"deleting": StorageVolumeState.DELETING,
}
LINODE_VOLUME_STATES = {
"creating": StorageVolumeState.CREATING,
"active": StorageVolumeState.AVAILABLE,
"resizing": StorageVolumeState.UPDATING,
"contact_support": StorageVolumeState.UNKNOWN,
}
def list_nodes(self):
"""
Returns a list of Linodes the API key in use has access
to view.
:return: List of node objects
:rtype: ``list`` of :class:`Node`
"""
data = self._paginated_request("/v4/linode/instances", "data")
return [self._to_node(obj) for obj in data]
def list_sizes(self):
"""
Returns a list of Linode Types
: rtype: ``list`` of :class: `NodeSize`
"""
data = self._paginated_request("/v4/linode/types", "data")
return [self._to_size(obj) for obj in data]
def list_images(self):
"""
Returns a list of images
:rtype: ``list`` of :class:`NodeImage`
"""
data = self._paginated_request("/v4/images", "data")
return [self._to_image(obj) for obj in data]
def list_locations(self):
"""
Lists the Regions available for Linode services
:rtype: ``list`` of :class:`NodeLocation`
"""
data = self._paginated_request("/v4/regions", "data")
return [self._to_location(obj) for obj in data]
def start_node(self, node):
"""Boots a node the API Key has permission to modify
:param node: the node to start
:type node: :class:`Node`
:rtype: ``bool``
"""
if not isinstance(node, Node):
raise LinodeExceptionV4("Invalid node instance")
response = self.connection.request("/v4/linode/instances/%s/boot" % node.id, method="POST")
return response.status == httplib.OK
def ex_start_node(self, node):
# NOTE: This method is here for backward compatibility reasons after
# this method was promoted to be part of the standard compute API in
# Libcloud v2.7.0
return self.start_node(node=node)
def stop_node(self, node):
"""Shuts down a a node the API Key has permission to modify.
:param node: the Linode to destroy
:type node: :class:`Node`
:rtype: ``bool``
"""
if not isinstance(node, Node):
raise LinodeExceptionV4("Invalid node instance")
response = self.connection.request(
"/v4/linode/instances/%s/shutdown" % node.id, method="POST"
)
return response.status == httplib.OK
def ex_stop_node(self, node):
# NOTE: This method is here for backward compatibility reasons after
# this method was promoted to be part of the standard compute API in
# Libcloud v2.7.0
return self.stop_node(node=node)
def destroy_node(self, node):
"""Deletes a node the API Key has permission to `read_write`
:param node: the Linode to destroy
:type node: :class:`Node`
:rtype: ``bool``
"""
if not isinstance(node, Node):
raise LinodeExceptionV4("Invalid node instance")
response = self.connection.request("/v4/linode/instances/%s" % node.id, method="DELETE")
return response.status == httplib.OK
def reboot_node(self, node):
"""Reboots a node the API Key has permission to modify.
:param node: the Linode to destroy
:type node: :class:`Node`
:rtype: ``bool``
"""
if not isinstance(node, Node):
raise LinodeExceptionV4("Invalid node instance")
response = self.connection.request(
"/v4/linode/instances/%s/reboot" % node.id, method="POST"
)
return response.status == httplib.OK
def create_node(
self,
location,
size,
image=None,
name=None,
root_pass=None,
ex_authorized_keys=None,
ex_authorized_users=None,
ex_tags=None,
ex_backups_enabled=False,
ex_private_ip=False,
):
"""Creates a Linode Instance.
In order for this request to complete successfully,
the user must have the `add_linodes` grant as this call
will incur a charge.
:param location: which region to create the node in
:type location: :class:`NodeLocation`
:param size: the plan size to create
:type size: :class:`NodeSize`
:keyword image: which distribution to deploy on the node
:type image: :class:`NodeImage`
:keyword name: the name to assign to node.\
Must start with an alpha character.\
May only consist of alphanumeric characters,\
dashes (-), underscores (_) or periods (.).\
Cannot have two dashes (--), underscores (__) or periods (..) in a row.
:type name: ``str``
:keyword root_pass: the root password (required if image is provided)
:type root_pass: ``str``
:keyword ex_authorized_keys: a list of public SSH keys
:type ex_authorized_keys: ``list`` of ``str``
:keyword ex_authorized_users: a list of usernames.\
If the usernames have associated SSH keys,\
the keys will be appended to the root users `authorized_keys`
:type ex_authorized_users: ``list`` of ``str``
:keyword ex_tags: list of tags for the node
:type ex_tags: ``list`` of ``str``
:keyword ex_backups_enabled: whether to be enrolled \
in the Linode Backup service (False)
:type ex_backups_enabled: ``bool``
:keyword ex_private_ip: whether or not to request a private IP
:type ex_private_ip: ``bool``
:return: Node representing the newly-created node
:rtype: :class:`Node`
"""
if not isinstance(location, NodeLocation):
raise LinodeExceptionV4("Invalid location instance")
if not isinstance(size, NodeSize):
raise LinodeExceptionV4("Invalid size instance")
attr = {
"region": location.id,
"type": size.id,
"private_ip": ex_private_ip,
"backups_enabled": ex_backups_enabled,
}
if image is not None:
if root_pass is None:
raise LinodeExceptionV4("root password required " "when providing an image")
attr["image"] = image.id
attr["root_pass"] = root_pass
if name is not None:
valid_name = r"^[a-zA-Z]((?!--|__|\.\.)[a-zA-Z0-9-_.])+$"
if not re.match(valid_name, name):
raise LinodeExceptionV4("Invalid name")
attr["label"] = name
if ex_authorized_keys is not None:
attr["authorized_keys"] = list(ex_authorized_keys)
if ex_authorized_users is not None:
attr["authorized_users"] = list(ex_authorized_users)
if ex_tags is not None:
attr["tags"] = list(ex_tags)
response = self.connection.request(
"/v4/linode/instances", data=json.dumps(attr), method="POST"
).object
return self._to_node(response)
def ex_get_node(self, node_id):
"""
Return a Node object based on a node ID.
:keyword node_id: Node's ID
:type node_id: ``str``
:return: Created node
:rtype : :class:`Node`
"""
response = self.connection.request("/v4/linode/instances/%s" % node_id).object
return self._to_node(response)
def ex_list_disks(self, node):
"""
List disks associated with the node.
:param node: Node to list disks. (required)
:type node: :class:`Node`
:rtype: ``list`` of :class:`LinodeDisk`
"""
if not isinstance(node, Node):
raise LinodeExceptionV4("Invalid node instance")
data = self._paginated_request("/v4/linode/instances/%s/disks" % node.id, "data")
return [self._to_disk(obj) for obj in data]
def ex_create_disk(
self,
size,
name,
node,
fs_type,
image=None,
ex_root_pass=None,
ex_authorized_keys=None,
ex_authorized_users=None,
ex_read_only=False,
):
"""
Adds a new disk to node
:param size: Size of disk in megabytes (required)
:type size: ``int``
:param name: Name of the disk to be created (required)
:type name: ``str``
:param node: Node to attach disk to (required)
:type node: :class:`Node`
:param fs_type: The formatted type of this disk. Valid types are:
ext3, ext4, swap, raw, initrd
:type fs_type: ``str``
:keyword image: Image to deploy the volume from
:type image: :class:`NodeImage`
:keyword ex_root_pass: root password,required \
if an image is provided
:type ex_root_pass: ``str``
:keyword ex_authorized_keys: a list of SSH keys
:type ex_authorized_keys: ``list`` of ``str``
:keyword ex_authorized_users: a list of usernames \
that will have their SSH keys,\
if any, automatically appended \
to the root user's ~/.ssh/authorized_keys file.
:type ex_authorized_users: ``list`` of ``str``
:keyword ex_read_only: if true, this disk is read-only
:type ex_read_only: ``bool``
:return: LinodeDisk representing the newly-created disk
:rtype: :class:`LinodeDisk`
"""
attr = {
"label": str(name),
"size": int(size),
"filesystem": fs_type,
"read_only": ex_read_only,
}
if not isinstance(node, Node):
raise LinodeExceptionV4("Invalid node instance")
if fs_type not in self._linode_disk_filesystems:
raise LinodeExceptionV4("Not valid filesystem type")
if image is not None:
if not isinstance(image, NodeImage):
raise LinodeExceptionV4("Invalid image instance")
# when an image is set, root pass must be set as well
if ex_root_pass is None:
raise LinodeExceptionV4("root_pass is required when " "deploying an image")
attr["image"] = image.id
attr["root_pass"] = ex_root_pass
if ex_authorized_keys is not None:
attr["authorized_keys"] = list(ex_authorized_keys)
if ex_authorized_users is not None:
attr["authorized_users"] = list(ex_authorized_users)
response = self.connection.request(
"/v4/linode/instances/%s/disks" % node.id,
data=json.dumps(attr),
method="POST",
).object
return self._to_disk(response)
def ex_destroy_disk(self, node, disk):
"""
Destroys disk for the given node.
:param node: The Node the disk is attached to. (required)
:type node: :class:`Node`
:param disk: LinodeDisk to be destroyed (required)
:type disk: :class:`LinodeDisk`
:rtype: ``bool``
"""
if not isinstance(node, Node):
raise LinodeExceptionV4("Invalid node instance")
if not isinstance(disk, LinodeDisk):
raise LinodeExceptionV4("Invalid disk instance")
if node.state != self.LINODE_STATES["stopped"]:
raise LinodeExceptionV4("Node needs to be stopped" " before disk is destroyed")
response = self.connection.request(
"/v4/linode/instances/{}/disks/{}".format(node.id, disk.id), method="DELETE"
)
return response.status == httplib.OK
def list_volumes(self):
"""Get all volumes of the account
:rtype: `list` of :class: `StorageVolume`
"""
data = self._paginated_request("/v4/volumes", "data")
return [self._to_volume(obj) for obj in data]
def create_volume(self, name, size, location=None, node=None, tags=None):
"""Creates a volume and optionally attaches it to a node.
:param name: The name to be given to volume (required).\
Must start with an alpha character. \
May only consist of alphanumeric characters,\
dashes (-), underscores (_)\
Cannot have two dashes (--), underscores (__) in a row.
:type name: `str`
:param size: Size in gigabytes (required)
:type size: `int`
:keyword location: Location to create the node.\
Required if node is not given.
:type location: :class:`NodeLocation`
:keyword volume: Node to attach the volume to
:type volume: :class:`Node`
:keyword tags: tags to apply to volume
:type tags: `list` of `str`
:rtype: :class: `StorageVolume`
"""
valid_name = "^[a-zA-Z]((?!--|__)[a-zA-Z0-9-_])+$"
if not re.match(valid_name, name):
raise LinodeExceptionV4("Invalid name")
attr = {
"label": name,
"size": int(size),
}
if node is not None:
if not isinstance(node, Node):
raise LinodeExceptionV4("Invalid node instance")
attr["linode_id"] = int(node.id)
else:
# location is only required if a node is not given
if location:
if not isinstance(location, NodeLocation):
raise LinodeExceptionV4("Invalid location instance")
attr["region"] = location.id
else:
raise LinodeExceptionV4("Region must be provided " "when node is not")
if tags is not None:
attr["tags"] = list(tags)
response = self.connection.request(
"/v4/volumes", data=json.dumps(attr), method="POST"
).object
return self._to_volume(response)
def attach_volume(self, node, volume, persist_across_boots=True):
"""Attaches a volume to a node.
Volume and node must be located in the same region
:param node: Node to attach the volume to(required)
:type node: :class:`Node`
:param volume: Volume to be attached (required)
:type volume: :class:`StorageVolume`
:keyword persist_across_boots: Wether volume should be \
attached to node across boots
:type persist_across_boots: `bool`
:rtype: :class: `StorageVolume`
"""
if not isinstance(volume, StorageVolume):
raise LinodeExceptionV4("Invalid volume instance")
if not isinstance(node, Node):
raise LinodeExceptionV4("Invalid node instance")
if volume.extra["linode_id"] is not None:
raise LinodeExceptionV4("Volume is already attached to a node")
if node.extra["location"] != volume.extra["location"]:
raise LinodeExceptionV4("Volume and node " "must be on the same region")
attr = {"linode_id": int(node.id), "persist_across_boots": persist_across_boots}
response = self.connection.request(
"/v4/volumes/%s/attach" % volume.id, data=json.dumps(attr), method="POST"
).object
return self._to_volume(response)
def detach_volume(self, volume):
"""Detaches a volume from a node.
:param volume: Volume to be detached (required)
:type volume: :class:`StorageVolume`
:rtype: ``bool``
"""
if not isinstance(volume, StorageVolume):
raise LinodeExceptionV4("Invalid volume instance")
if volume.extra["linode_id"] is None:
raise LinodeExceptionV4("Volume is already detached")
response = self.connection.request("/v4/volumes/%s/detach" % volume.id, method="POST")
return response.status == httplib.OK
def destroy_volume(self, volume):
"""Destroys the volume given.
:param volume: Volume to be deleted (required)
:type volume: :class:`StorageVolume`
:rtype: ``bool``
"""
if not isinstance(volume, StorageVolume):
raise LinodeExceptionV4("Invalid volume instance")
if volume.extra["linode_id"] is not None:
raise LinodeExceptionV4("Volume must be detached" " before it can be deleted.")
response = self.connection.request("/v4/volumes/%s" % volume.id, method="DELETE")
return response.status == httplib.OK
def ex_resize_volume(self, volume, size):
"""Resizes the volume given.
:param volume: Volume to be resized
:type volume: :class:`StorageVolume`
:param size: new volume size in gigabytes, must be\
greater than current size
:type size: `int`
:rtype: ``bool``
"""
if not isinstance(volume, StorageVolume):
raise LinodeExceptionV4("Invalid volume instance")
if volume.size >= size:
raise LinodeExceptionV4("Volumes can only be resized up")
attr = {"size": size}
response = self.connection.request(
"/v4/volumes/%s/resize" % volume.id, data=json.dumps(attr), method="POST"
)
return response.status == httplib.OK
def ex_clone_volume(self, volume, name):
"""Clones the volume given
:param volume: Volume to be cloned
:type volume: :class:`StorageVolume`
:param name: new cloned volume name
:type name: `str`
:rtype: :class:`StorageVolume`
"""
if not isinstance(volume, StorageVolume):
raise LinodeExceptionV4("Invalid volume instance")
attr = {"label": name}
response = self.connection.request(
"/v4/volumes/%s/clone" % volume.id, data=json.dumps(attr), method="POST"
).object
return self._to_volume(response)
def ex_get_volume(self, volume_id):
"""
Return a Volume object based on a volume ID.
:param volume_id: Volume's id
:type volume_id: ``str``
:return: A StorageVolume object for the volume
:rtype: :class:`StorageVolume`
"""
response = self.connection.request("/v4/volumes/%s" % volume_id).object
return self._to_volume(response)
def create_image(self, disk, name=None, description=None):
"""Creates a private image from a LinodeDisk.
Images are limited to three per account.
:param disk: LinodeDisk to create the image from (required)
:type disk: :class:`LinodeDisk`
:keyword name: A name for the image.\
Defaults to the name of the disk \
it is being created from if not provided
:type name: `str`
:keyword description: A description of the image
:type description: `str`
:return: The newly created NodeImage
:rtype: :class:`NodeImage`
"""
if not isinstance(disk, LinodeDisk):
raise LinodeExceptionV4("Invalid disk instance")
attr = {"disk_id": int(disk.id), "label": name, "description": description}
response = self.connection.request(
"/v4/images", data=json.dumps(attr), method="POST"
).object
return self._to_image(response)
def delete_image(self, image):
"""Deletes a private image
:param image: NodeImage to delete (required)
:type image: :class:`NodeImage`
:rtype: ``bool``
"""
if not isinstance(image, NodeImage):
raise LinodeExceptionV4("Invalid image instance")
response = self.connection.request("/v4/images/%s" % image.id, method="DELETE")
return response.status == httplib.OK
def ex_list_addresses(self):
"""List IP addresses
:return: LinodeIPAddress list
:rtype: `list` of :class:`LinodeIPAddress`
"""
data = self._paginated_request("/v4/networking/ips", "data")
return [self._to_address(obj) for obj in data]
def ex_list_node_addresses(self, node):
"""List all IPv4 addresses attached to node
:param node: Node to list IP addresses
:type node: :class:`Node`
:return: LinodeIPAddress list
:rtype: `list` of :class:`LinodeIPAddress`
"""
if not isinstance(node, Node):
raise LinodeExceptionV4("Invalid node instance")
response = self.connection.request("/v4/linode/instances/%s/ips" % node.id).object
return self._to_addresses(response)
def ex_allocate_private_address(self, node, address_type="ipv4"):
"""Allocates a private IPv4 address to node.Only ipv4 is currently supported
:param node: Node to attach the IP address
:type node: :class:`Node`
:keyword address_type: Type of IP address
:type address_type: `str`
:return: The newly created LinodeIPAddress
:rtype: :class:`LinodeIPAddress`
"""
if not isinstance(node, Node):
raise LinodeExceptionV4("Invalid node instance")
# Only ipv4 is currently supported
if address_type != "ipv4":
raise LinodeExceptionV4("Address type not supported")
# Only one private IP address can be allocated
if len(node.private_ips) >= 1:
raise LinodeExceptionV4("Nodes can have up to one private IP")
attr = {"public": False, "type": address_type}
response = self.connection.request(
"/v4/linode/instances/%s/ips" % node.id,
data=json.dumps(attr),
method="POST",
).object
return self._to_address(response)
def ex_share_address(self, node, addresses):
"""Shares an IP with another node.This can be used to allow one Linode
to begin serving requests should another become unresponsive.
:param node: Node to share the IP addresses with
:type node: :class:`Node`
:keyword addresses: List of IP addresses to share
:type address_type: `list` of :class: `LinodeIPAddress`
:rtype: ``bool``
"""
if not isinstance(node, Node):
raise LinodeExceptionV4("Invalid node instance")
if not all(isinstance(address, LinodeIPAddress) for address in addresses):
raise LinodeExceptionV4("Invalid address instance")
attr = {
"ips": [address.inet for address in addresses],
"linode_id": int(node.id),
}
response = self.connection.request(
"/v4/networking/ipv4/share", data=json.dumps(attr), method="POST"
)
return response.status == httplib.OK
def ex_resize_node(self, node, size, allow_auto_disk_resize=False):
"""
Resizes a node the API Key has read_write permission
to a different Type.
The following requirements must be met:
- The node must not have a pending migration
- The account cannot have an outstanding balance
- The node must not have more disk allocation than the new size allows
:param node: the Linode to resize
:type node: :class:`Node`
:param size: the size of the new node
:type size: :class:`NodeSize`
:keyword allow_auto_disk_resize: Automatically resize disks \
when resizing a node.
:type allow_auto_disk_resize: ``bool``
:rtype: ``bool``
"""
if not isinstance(node, Node):
raise LinodeExceptionV4("Invalid node instance")
if not isinstance(size, NodeSize):
raise LinodeExceptionV4("Invalid node size")
attr = {"type": size.id, "allow_auto_disk_resize": allow_auto_disk_resize}
response = self.connection.request(
"/v4/linode/instances/%s/resize" % node.id,
data=json.dumps(attr),
method="POST",
)
return response.status == httplib.OK
def ex_rename_node(self, node, name):
"""Renames a node
:param node: the Linode to resize
:type node: :class:`Node`
:param name: the node's new name
:type name: ``str``
:return: Changed Node
:rtype: :class:`Node`
"""
if not isinstance(node, Node):
raise LinodeExceptionV4("Invalid node instance")
attr = {"label": name}
response = self.connection.request(
"/v4/linode/instances/%s" % node.id, data=json.dumps(attr), method="PUT"
).object
return self._to_node(response)
def _to_node(self, data):
extra = {
"tags": data["tags"],
"location": data["region"],
"ipv6": data["ipv6"],
"hypervisor": data["hypervisor"],
"specs": data["specs"],
"alerts": data["alerts"],
"backups": data["backups"],
"watchdog_enabled": data["watchdog_enabled"],
}
public_ips = [ip for ip in data["ipv4"] if not is_private_subnet(ip)]
private_ips = [ip for ip in data["ipv4"] if is_private_subnet(ip)]
return Node(
id=data["id"],
name=data["label"],
state=self.LINODE_STATES[data["status"]],
public_ips=public_ips,
private_ips=private_ips,
driver=self,
size=data["type"],
image=data["image"],
created_at=self._to_datetime(data["created"]),
extra=extra,
)
def _to_datetime(self, strtime):
return datetime.strptime(strtime, "%Y-%m-%dT%H:%M:%S")
def _to_size(self, data):
extra = {
"class": data["class"],
"monthly_price": data["price"]["monthly"],
"addons": data["addons"],
"successor": data["successor"],
"transfer": data["transfer"],
"vcpus": data["vcpus"],
"gpus": data["gpus"],
}
return NodeSize(
id=data["id"],
name=data["label"],
ram=data["memory"],
disk=data["disk"],
bandwidth=data["network_out"],
price=data["price"]["hourly"],
driver=self,
extra=extra,
)
def _to_image(self, data):
extra = {
"type": data["type"],
"description": data["description"],
"created": self._to_datetime(data["created"]),
"created_by": data["created_by"],
"is_public": data["is_public"],
"size": data["size"],
"eol": data["eol"],
"vendor": data["vendor"],
}
return NodeImage(id=data["id"], name=data["label"], driver=self, extra=extra)
def _to_location(self, data):
extra = {
"status": data["status"],
"capabilities": data["capabilities"],
"resolvers": data["resolvers"],
}
return NodeLocation(
id=data["id"],
name=data["id"],
country=data["country"].upper(),
driver=self,
extra=extra,
)
def _to_volume(self, data):
extra = {
"created": self._to_datetime(data["created"]),
"tags": data["tags"],
"location": data["region"],
"linode_id": data["linode_id"],
"linode_label": data["linode_label"],
"state": self.LINODE_VOLUME_STATES[data["status"]],
"filesystem_path": data["filesystem_path"],
}
return StorageVolume(
id=str(data["id"]),
name=data["label"],
size=data["size"],
driver=self,
extra=extra,
)
def _to_disk(self, data):
return LinodeDisk(
id=data["id"],
state=self.LINODE_DISK_STATES[data["status"]],
name=data["label"],
filesystem=data["filesystem"],
size=data["size"],
driver=self,
)
def _to_address(self, data):
extra = {
"gateway": data["gateway"],
"subnet_mask": data["subnet_mask"],
"prefix": data["prefix"],
"rdns": data["rdns"],
"node_id": data["linode_id"],
"region": data["region"],
}
return LinodeIPAddress(
inet=data["address"],
public=data["public"],
version=data["type"],
driver=self,
extra=extra,
)
def _to_addresses(self, data):
addresses = data["ipv4"]["public"] + data["ipv4"]["private"]
return [self._to_address(address) for address in addresses]
def _paginated_request(self, url, obj, params=None):
"""
Perform multiple calls in order to have a full list of elements when
the API responses are paginated.
:param url: API endpoint
:type url: ``str``
:param obj: Result object key
:type obj: ``str``
:param params: Request parameters
:type params: ``dict``
:return: ``list`` of API response objects
:rtype: ``list``
"""
objects = []
params = params if params is not None else {}
ret = self.connection.request(url, params=params).object
data = list(ret.get(obj, []))
current_page = int(ret.get("page", 1))
num_of_pages = int(ret.get("pages", 1))
objects.extend(data)
for page in range(current_page + 1, num_of_pages + 1):
# add param to request next page
params["page"] = page
ret = self.connection.request(url, params=params).object
data = list(ret.get(obj, []))
objects.extend(data)
return objects