| # 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. |
| # libcloud.org 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. |
| # |
| # Maintainer: Jed Smith <jsmith@linode.com> |
| # |
| # BETA TESTING THE LINODE API AND DRIVERS |
| # |
| # A beta account that incurs no financial charge may be arranged for. Please |
| # contact Jed Smith <jsmith@linode.com> for your request. |
| # |
| """ |
| Linode driver |
| """ |
| from libcloud.types import Provider, NodeState, InvalidCredsException |
| from libcloud.base import ConnectionKey, Response |
| from libcloud.base import NodeDriver, NodeSize, Node, NodeLocation |
| from libcloud.base import NodeAuthPassword, NodeAuthSSHKey |
| from libcloud.base import NodeImage |
| from copy import copy |
| import os |
| |
| # JSON is included in the standard library starting with Python 2.6. For 2.5 |
| # and 2.4, there's a simplejson egg at: http://pypi.python.org/pypi/simplejson |
| try: import json |
| except: import simplejson as json |
| |
| |
| # Base exception for problems arising from this driver |
| class LinodeException(Exception): |
| def __str__(self): |
| return "(%u) %s" % (self.args[0], self.args[1]) |
| def __repr__(self): |
| return "<LinodeException code %u '%s'>" % (self.args[0], self.args[1]) |
| |
| # For beta accounts, change this to "beta.linode.com". |
| LINODE_API = "api.linode.com" |
| |
| # For beta accounts, change this to "/api/". |
| LINODE_ROOT = "/" |
| |
| |
| class LinodeResponse(Response): |
| # Wraps a Linode API HTTP response. |
| |
| def __init__(self, response): |
| # Given a response object, slurp the information from it. |
| self.body = response.read() |
| self.status = response.status |
| self.headers = dict(response.getheaders()) |
| self.error = response.reason |
| self.invalid = LinodeException(0xFF, |
| "Invalid JSON received from server") |
| |
| # Move parse_body() to here; we can't be sure of failure until we've |
| # parsed the body into JSON. |
| self.action, self.object, self.errors = self.parse_body() |
| |
| if self.error == "Moved Temporarily": |
| raise LinodeException(0xFA, |
| "Redirected to error page by API. Bug?") |
| |
| if not self.success(): |
| # Raise the first error, as there will usually only be one |
| raise self.errors[0] |
| |
| def parse_body(self): |
| # Parse the body of the response into JSON. Will return None if the |
| # JSON response chokes the parser. Returns a triple: |
| # (action, data, errorarray) |
| try: |
| js = json.loads(self.body) |
| if ("DATA" not in js |
| or "ERRORARRAY" not in js |
| or "ACTION" not in js): |
| |
| return (None, None, [self.invalid]) |
| errs = [self._make_excp(e) for e in js["ERRORARRAY"]] |
| return (js["ACTION"], js["DATA"], errs) |
| except: |
| # Assume invalid JSON, and use an error code unused by Linode API. |
| return (None, None, [self.invalid]) |
| |
| def parse_error(self): |
| # Obtain the errors from the response. Will always return a list. |
| try: |
| js = json.loads(self.body) |
| if "ERRORARRAY" not in js: |
| return [self.invalid] |
| return [self._make_excp(e) for e in js["ERRORARRAY"]] |
| except: |
| return [self.invalid] |
| |
| def success(self): |
| # Does the response indicate success? If ERRORARRAY has more than one |
| # entry, we'll say no. |
| return len(self.errors) == 0 |
| |
| def _make_excp(self, error): |
| # Make an exception from an entry in ERRORARRAY. |
| if "ERRORCODE" not in error or "ERRORMESSAGE" not in error: |
| return None |
| if error["ERRORCODE"] == 4: |
| return InvalidCredsException(error["ERRORMESSAGE"]) |
| return LinodeException(error["ERRORCODE"], error["ERRORMESSAGE"]) |
| |
| |
| class LinodeConnection(ConnectionKey): |
| # Wraps a Linode HTTPS connection, and passes along the connection key. |
| host = LINODE_API |
| responseCls = LinodeResponse |
| def add_default_params(self, params): |
| params["api_key"] = self.key |
| # Be explicit about this in case the default changes. |
| params["api_responseFormat"] = "json" |
| return params |
| |
| |
| class LinodeNodeDriver(NodeDriver): |
| # The meat of Linode operations; the Node Driver. |
| type = Provider.LINODE |
| name = "Linode" |
| connectionCls = LinodeConnection |
| |
| def __init__(self, key): |
| self.datacenter = None |
| NodeDriver.__init__(self, key) |
| |
| # Converts Linode's state from DB to a NodeState constant. |
| # Some of these are lightly questionable. |
| LINODE_STATES = { |
| -2: NodeState.UNKNOWN, # Boot Failed |
| -1: NodeState.PENDING, # Being Created |
| 0: NodeState.PENDING, # Brand New |
| 1: NodeState.RUNNING, # Running |
| 2: NodeState.REBOOTING, # Powered Off (TODO: Extra state?) |
| 3: NodeState.REBOOTING, # Shutting Down (?) |
| 4: NodeState.UNKNOWN # Reserved |
| } |
| |
| def list_nodes(self): |
| # List |
| # Provide a list of all nodes that this API key has access to. |
| params = { "api_action": "linode.list" } |
| data = self.connection.request(LINODE_ROOT, params=params).object |
| return [self._to_node(n) for n in data] |
| |
| def reboot_node(self, node): |
| # Reboot |
| # Execute a shutdown and boot job for the given Node. |
| params = { "api_action": "linode.reboot", "LinodeID": node.id } |
| self.connection.request(LINODE_ROOT, params=params) |
| return True |
| |
| def destroy_node(self, node): |
| # Destroy |
| # Terminates a Node. With prejudice. |
| params = { "api_action": "linode.delete", "LinodeID": node.id, |
| "skipChecks": True } |
| self.connection.request(LINODE_ROOT, params=params) |
| return True |
| |
| def create_node(self, **kwargs): |
| """Create a new linode instance |
| |
| See L{NodeDriver.create_node} for more keyword args. |
| |
| @keyword swap: Size of the swap partition in MB (128). |
| @type swap: C{number} |
| |
| @keyword rsize: Size of the root partition (plan size - swap). |
| @type rsize: C{number} |
| |
| @keyword kernel: A kernel ID from avail.kernels (Latest 2.6). |
| @type kernel: C{number} |
| |
| @keyword payment: One of 1, 12, or 24; subscription length (1) |
| @type payment: C{number} |
| |
| @keyword comment: Comments to store with the config |
| @type comment: C{str} |
| """ |
| # Labels to override what's generated (default on right): |
| # lconfig [%name] Instance |
| # lrecovery [%name] Finnix Recovery Configuration |
| # lroot [%name] %distro |
| # lswap [%name] Swap Space |
| # |
| # Datacenter logic: |
| # |
| # As Linode requires choosing a datacenter, a little logic is done. |
| # |
| # 1. If the API key in use has all its Linodes in one DC, that DC will |
| # be chosen (and can be overridden with linode_set_datacenter). |
| # |
| # 2. Otherwise (for both the "No Linodes" and "different DC" cases), a |
| # datacenter must explicitly be chosen using linode_set_datacenter. |
| # |
| # Please note that for safety, only 5 Linodes can be created per hour. |
| |
| name = kwargs["name"] |
| chosen = kwargs["location"].id |
| image = kwargs["image"] |
| size = kwargs["size"] |
| auth = kwargs["auth"] |
| |
| # 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 "payment" not in kwargs else str(kwargs["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 |
| elif isinstance(auth, NodeAuthPassword): |
| root = auth.password |
| |
| if not ssh and not root: |
| raise LinodeException(0xFB, "Need SSH key or root password") |
| if len(root) < 6: |
| raise LinodeException(0xFB, "Root password is too short") |
| |
| # Swap size |
| try: swap = 128 if "swap" not in kwargs else int(kwargs["swap"]) |
| except: raise LinodeException(0xFB, "Need an integer swap size") |
| |
| # Root partition size |
| imagesize = (size.disk - swap) if "rsize" not in kwargs else \ |
| int(kwargs["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 |
| kernel = 60 if "kernel" not in kwargs else kwargs["kernel"] |
| params = { "api_action": "avail.kernels" } |
| kernels = self.connection.request(LINODE_ROOT, params=params).object |
| if kernel not in [z["KERNELID"] for z in kernels]: |
| raise LinodeException(0xFB, "Invalid kernel -- avail.kernels") |
| |
| # Comments |
| comments = "Created by libcloud <http://www.libcloud.org>" if \ |
| "comment" not in kwargs else kwargs["comment"] |
| |
| # Labels |
| label = { |
| "lconfig": "[%s] Configuration Profile" % name, |
| "lrecovery": "[%s] Finnix Recovery Configuration" % name, |
| "lroot": "[%s] %s Disk Image" % (name, image.name), |
| "lswap": "[%s] Swap Space" % name |
| } |
| for what in ["lconfig", "lrecovery", "lroot", "lswap"]: |
| if what in kwargs: |
| label[what] = kwargs[what] |
| |
| # Step 1: linode.create |
| params = { |
| "api_action": "linode.create", |
| "DatacenterID": chosen, |
| "PlanID": size.id, |
| "PaymentTerm": payment |
| } |
| data = self.connection.request(LINODE_ROOT, params=params).object |
| linode = { "id": data["LinodeID"] } |
| |
| # Step 2: linode.disk.createfromdistribution |
| if not root: |
| root = os.urandom(16).encode('hex') |
| 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(LINODE_ROOT, params=params).object |
| 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(LINODE_ROOT, params=params).object |
| linode["swapimage"] = data["DiskID"] |
| |
| # Step 4: linode.config.create for main profile |
| disks = "%s,%s,,,,,,," % (linode["rootimage"], linode["swapimage"]) |
| params = { |
| "api_action": "linode.config.create", |
| "LinodeID": linode["id"], |
| "KernelID": kernel, |
| "Label": label["lconfig"], |
| "Comments": comments, |
| "DiskList": disks |
| } |
| data = self.connection.request(LINODE_ROOT, params=params).object |
| linode["config"] = data["ConfigID"] |
| |
| # TODO: Recovery image (Finnix) |
| |
| # Step 5: linode.boot |
| params = { |
| "api_action": "linode.boot", |
| "LinodeID": linode["id"], |
| "ConfigID": linode["config"] |
| } |
| data = self.connection.request(LINODE_ROOT, params=params).object |
| |
| # Make a node out of it and hand it back |
| params = { "api_action": "linode.list", "LinodeID": linode["id"] } |
| data = self.connection.request(LINODE_ROOT, params=params).object |
| return self._to_node(data[0]) |
| |
| def list_sizes(self, location=None): |
| # List Sizes |
| # Retrieve all available Linode plans. |
| # FIXME: Prices get mangled due to 'float'. |
| params = { "api_action": "avail.linodeplans" } |
| data = self.connection.request(LINODE_ROOT, params=params).object |
| 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, location=None): |
| # List Images |
| # Retrieve all available Linux distributions. |
| params = { "api_action": "avail.distributions" } |
| data = self.connection.request(LINODE_ROOT, params=params).object |
| distros = [] |
| for obj in data: |
| i = NodeImage(id=obj["DISTRIBUTIONID"], name=obj["LABEL"], |
| driver=self.connection.driver) |
| distros.append(i) |
| return distros |
| |
| def list_locations(self): |
| params = { "api_action": "avail.datacenters" } |
| data = self.connection.request(LINODE_ROOT, params=params).object |
| nl = [] |
| for dc in data: |
| country = None |
| #TODO: this is a hack! |
| if dc["LOCATION"][-3:] == "USA": |
| country = "US" |
| elif dc["LOCATION"][-2:] == "UK": |
| country = "GB" |
| else: |
| raise LinodeException( |
| 0xFD, |
| "Unable to convert data center location to country: '%s'" |
| % dc["LOCATION"] |
| ) |
| nl.append(NodeLocation(dc["DATACENTERID"], |
| dc["LOCATION"], |
| country, |
| self)) |
| return nl |
| |
| def linode_set_datacenter(self, did): |
| # Set the datacenter for create requests. |
| # |
| # Create will try to guess, based on where all of the API key's |
| # Linodes are located; if they are all in one location, Create will |
| # make a new node there. If there are NO Linodes on the account or |
| # Linodes are in multiple locations, it is imperative to set this or |
| # creates will fail. |
| params = { "api_action": "avail.datacenters" } |
| data = self.connection.request(LINODE_ROOT, params=params).object |
| for dc 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 _to_node(self, obj): |
| # Convert a returned Linode instance into a Node instance. |
| lid = obj["LINODEID"] |
| |
| # Get the IP addresses for a Linode |
| params = { "api_action": "linode.ip.list", "LinodeID": lid } |
| req = self.connection.request(LINODE_ROOT, params=params) |
| if not req.success() or len(req.object) == 0: |
| return None |
| |
| public_ip = [] |
| private_ip = [] |
| for ip in req.object: |
| if ip["ISPUBLIC"]: |
| public_ip.append(ip["IPADDRESS"]) |
| else: |
| private_ip.append(ip["IPADDRESS"]) |
| |
| n = Node(id=lid, name=obj["LABEL"], |
| state=self.LINODE_STATES[obj["STATUS"]], public_ip=public_ip, |
| private_ip=private_ip, driver=self.connection.driver) |
| n.extra = copy(obj) |
| return n |
| |
| features = {"create_node": ["ssh_key", "password"]} |