498 lines
16 KiB
Python
498 lines
16 KiB
Python
# 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.
|
|
|
|
"""
|
|
Base driver for the providers based on the ElasticStack platform -
|
|
http://www.elasticstack.com.
|
|
"""
|
|
|
|
import re
|
|
import time
|
|
import base64
|
|
|
|
from libcloud.utils.py3 import httplib
|
|
from libcloud.utils.py3 import b
|
|
|
|
try:
|
|
import simplejson as json
|
|
except ImportError:
|
|
import json
|
|
|
|
from libcloud.common.base import ConnectionUserAndKey, JsonResponse
|
|
from libcloud.common.types import InvalidCredsError
|
|
from libcloud.compute.types import NodeState
|
|
from libcloud.compute.base import NodeDriver, NodeSize, Node
|
|
from libcloud.compute.base import NodeImage
|
|
from libcloud.compute.deployment import ScriptDeployment, SSHKeyDeployment
|
|
from libcloud.compute.deployment import MultiStepDeployment
|
|
|
|
|
|
NODE_STATE_MAP = {
|
|
'active': NodeState.RUNNING,
|
|
'dead': NodeState.TERMINATED,
|
|
'dumped': NodeState.TERMINATED,
|
|
}
|
|
|
|
# Default timeout (in seconds) for the drive imaging process
|
|
IMAGING_TIMEOUT = 10 * 60
|
|
|
|
# ElasticStack doesn't specify special instance types, so I just specified
|
|
# some plans based on the other provider offerings.
|
|
#
|
|
# Basically for CPU any value between 500Mhz and 20000Mhz should work,
|
|
# 256MB to 8192MB for ram and 1GB to 2TB for disk.
|
|
INSTANCE_TYPES = {
|
|
'small': {
|
|
'id': 'small',
|
|
'name': 'Small instance',
|
|
'cpu': 2000,
|
|
'memory': 1700,
|
|
'disk': 160,
|
|
'bandwidth': None,
|
|
},
|
|
'medium': {
|
|
'id': 'medium',
|
|
'name': 'Medium instance',
|
|
'cpu': 3000,
|
|
'memory': 4096,
|
|
'disk': 500,
|
|
'bandwidth': None,
|
|
},
|
|
'large': {
|
|
'id': 'large',
|
|
'name': 'Large instance',
|
|
'cpu': 4000,
|
|
'memory': 7680,
|
|
'disk': 850,
|
|
'bandwidth': None,
|
|
},
|
|
'extra-large': {
|
|
'id': 'extra-large',
|
|
'name': 'Extra Large instance',
|
|
'cpu': 8000,
|
|
'memory': 8192,
|
|
'disk': 1690,
|
|
'bandwidth': None,
|
|
},
|
|
'high-cpu-medium': {
|
|
'id': 'high-cpu-medium',
|
|
'name': 'High-CPU Medium instance',
|
|
'cpu': 5000,
|
|
'memory': 1700,
|
|
'disk': 350,
|
|
'bandwidth': None,
|
|
},
|
|
'high-cpu-extra-large': {
|
|
'id': 'high-cpu-extra-large',
|
|
'name': 'High-CPU Extra Large instance',
|
|
'cpu': 20000,
|
|
'memory': 7168,
|
|
'disk': 1690,
|
|
'bandwidth': None,
|
|
},
|
|
}
|
|
|
|
|
|
class ElasticStackException(Exception):
|
|
def __str__(self):
|
|
# pylint: disable=unsubscriptable-object
|
|
return self.args[0]
|
|
|
|
def __repr__(self):
|
|
# pylint: disable=unsubscriptable-object
|
|
return "<ElasticStackException '%s'>" % (self.args[0])
|
|
|
|
|
|
class ElasticStackResponse(JsonResponse):
|
|
def success(self):
|
|
if self.status == 401:
|
|
raise InvalidCredsError()
|
|
|
|
return 200 <= self.status <= 299
|
|
|
|
def parse_error(self):
|
|
error_header = self.headers.get('x-elastic-error', '')
|
|
return 'X-Elastic-Error: %s (%s)' % (error_header, self.body.strip())
|
|
|
|
|
|
class ElasticStackNodeSize(NodeSize):
|
|
def __init__(self, id, name, cpu, ram, disk, bandwidth, price, driver):
|
|
self.id = id
|
|
self.name = name
|
|
self.cpu = cpu
|
|
self.ram = ram
|
|
self.disk = disk
|
|
self.bandwidth = bandwidth
|
|
self.price = price
|
|
self.driver = driver
|
|
|
|
def __repr__(self):
|
|
return (('<NodeSize: id=%s, name=%s, cpu=%s, ram=%s '
|
|
'disk=%s bandwidth=%s price=%s driver=%s ...>')
|
|
% (self.id, self.name, self.cpu, self.ram,
|
|
self.disk, self.bandwidth, self.price, self.driver.name))
|
|
|
|
|
|
class ElasticStackBaseConnection(ConnectionUserAndKey):
|
|
"""
|
|
Base connection class for the ElasticStack driver
|
|
"""
|
|
|
|
host = None
|
|
responseCls = ElasticStackResponse
|
|
|
|
def add_default_headers(self, headers):
|
|
headers['Accept'] = 'application/json'
|
|
headers['Content-Type'] = 'application/json'
|
|
headers['Authorization'] = \
|
|
('Basic %s' % (base64.b64encode(b('%s:%s' % (self.user_id,
|
|
self.key))))
|
|
.decode('utf-8'))
|
|
return headers
|
|
|
|
|
|
class ElasticStackBaseNodeDriver(NodeDriver):
|
|
website = 'http://www.elasticstack.com'
|
|
connectionCls = ElasticStackBaseConnection
|
|
features = {"create_node": ["generates_password"]}
|
|
|
|
# Dynamically populated by sub-classes
|
|
_standard_drives = {}
|
|
|
|
def reboot_node(self, node):
|
|
# Reboots the node
|
|
response = self.connection.request(
|
|
action='/servers/%s/reset' % (node.id),
|
|
method='POST'
|
|
)
|
|
return response.status == 204
|
|
|
|
def destroy_node(self, node):
|
|
# Kills the server immediately
|
|
response = self.connection.request(
|
|
action='/servers/%s/destroy' % (node.id),
|
|
method='POST'
|
|
)
|
|
return response.status == 204
|
|
|
|
def list_images(self, location=None):
|
|
# Returns a list of available pre-installed system drive images
|
|
images = []
|
|
for key, value in self._standard_drives.items():
|
|
image = NodeImage(
|
|
id=value['uuid'],
|
|
name=value['description'],
|
|
driver=self.connection.driver,
|
|
extra={
|
|
'size_gunzipped': value['size_gunzipped']
|
|
}
|
|
)
|
|
images.append(image)
|
|
|
|
return images
|
|
|
|
def list_sizes(self, location=None):
|
|
sizes = []
|
|
for key, value in INSTANCE_TYPES.items():
|
|
size = ElasticStackNodeSize(
|
|
id=value['id'],
|
|
name=value['name'], cpu=value['cpu'], ram=value['memory'],
|
|
disk=value['disk'], bandwidth=value['bandwidth'],
|
|
price=self._get_size_price(size_id=value['id']),
|
|
driver=self.connection.driver
|
|
)
|
|
sizes.append(size)
|
|
|
|
return sizes
|
|
|
|
def list_nodes(self):
|
|
# Returns a list of active (running) nodes
|
|
response = self.connection.request(action='/servers/info').object
|
|
|
|
nodes = []
|
|
for data in response:
|
|
node = self._to_node(data)
|
|
nodes.append(node)
|
|
|
|
return nodes
|
|
|
|
def create_node(self, name, size, image, smp='auto', nic_model='e1000',
|
|
vnc_password=None, drive_type='hdd'):
|
|
"""Creates an ElasticStack instance
|
|
|
|
@inherits: :class:`NodeDriver.create_node`
|
|
|
|
:keyword name: String with a name for this new node (required)
|
|
:type name: ``str``
|
|
|
|
:keyword smp: Number of virtual processors or None to calculate
|
|
based on the cpu speed
|
|
:type smp: ``int``
|
|
|
|
:keyword nic_model: e1000, rtl8139 or virtio
|
|
(if not specified, e1000 is used)
|
|
:type nic_model: ``str``
|
|
|
|
:keyword vnc_password: If set, the same password is also used for
|
|
SSH access with user toor,
|
|
otherwise VNC access is disabled and
|
|
no SSH login is possible.
|
|
:type vnc_password: ``str``
|
|
"""
|
|
ssh_password = vnc_password
|
|
|
|
if nic_model not in ('e1000', 'rtl8139', 'virtio'):
|
|
raise ElasticStackException('Invalid NIC model specified')
|
|
|
|
# check that drive size is not smaller than pre installed image size
|
|
|
|
# First we create a drive with the specified size
|
|
drive_data = {}
|
|
drive_data.update({'name': name,
|
|
'size': '%sG' % (size.disk)})
|
|
|
|
response = self.connection.request(action='/drives/create',
|
|
data=json.dumps(drive_data),
|
|
method='POST').object
|
|
|
|
if not response:
|
|
raise ElasticStackException('Drive creation failed')
|
|
|
|
drive_uuid = response['drive']
|
|
|
|
# Then we image the selected pre-installed system drive onto it
|
|
response = self.connection.request(
|
|
action='/drives/%s/image/%s/gunzip' % (drive_uuid, image.id),
|
|
method='POST'
|
|
)
|
|
|
|
if response.status not in (200, 204):
|
|
raise ElasticStackException('Drive imaging failed')
|
|
|
|
# We wait until the drive is imaged and then boot up the node
|
|
# (in most cases, the imaging process shouldn't take longer
|
|
# than a few minutes)
|
|
response = self.connection.request(
|
|
action='/drives/%s/info' % (drive_uuid)
|
|
).object
|
|
|
|
imaging_start = time.time()
|
|
while 'imaging' in response:
|
|
response = self.connection.request(
|
|
action='/drives/%s/info' % (drive_uuid)
|
|
).object
|
|
|
|
elapsed_time = time.time() - imaging_start
|
|
if ('imaging' in response and elapsed_time >= IMAGING_TIMEOUT):
|
|
raise ElasticStackException('Drive imaging timed out')
|
|
|
|
time.sleep(1)
|
|
|
|
node_data = {}
|
|
node_data.update({'name': name,
|
|
'cpu': size.cpu,
|
|
'mem': size.ram,
|
|
'ide:0:0': drive_uuid,
|
|
'boot': 'ide:0:0',
|
|
'smp': smp})
|
|
node_data.update({'nic:0:model': nic_model, 'nic:0:dhcp': 'auto'})
|
|
|
|
if vnc_password:
|
|
node_data.update({'vnc': 'auto', 'vnc:password': vnc_password})
|
|
|
|
response = self.connection.request(
|
|
action='/servers/create', data=json.dumps(node_data),
|
|
method='POST'
|
|
).object
|
|
|
|
if isinstance(response, list):
|
|
nodes = [self._to_node(node, ssh_password) for node in response]
|
|
else:
|
|
nodes = self._to_node(response, ssh_password)
|
|
|
|
return nodes
|
|
|
|
# Extension methods
|
|
def ex_set_node_configuration(self, node, **kwargs):
|
|
"""
|
|
Changes the configuration of the running server
|
|
|
|
:param node: Node which should be used
|
|
:type node: :class:`Node`
|
|
|
|
:param kwargs: keyword arguments
|
|
:type kwargs: ``dict``
|
|
|
|
:rtype: ``bool``
|
|
"""
|
|
valid_keys = ('^name$', '^parent$', '^cpu$', '^smp$', '^mem$',
|
|
'^boot$', '^nic:0:model$', '^nic:0:dhcp',
|
|
'^nic:1:model$', '^nic:1:vlan$', '^nic:1:mac$',
|
|
'^vnc:ip$', '^vnc:password$', '^vnc:tls',
|
|
'^ide:[0-1]:[0-1](:media)?$',
|
|
'^scsi:0:[0-7](:media)?$', '^block:[0-7](:media)?$')
|
|
|
|
invalid_keys = []
|
|
keys = list(kwargs.keys())
|
|
for key in keys:
|
|
matches = False
|
|
for regex in valid_keys:
|
|
if re.match(regex, key):
|
|
matches = True
|
|
break
|
|
if not matches:
|
|
invalid_keys.append(key)
|
|
|
|
if invalid_keys:
|
|
raise ElasticStackException(
|
|
'Invalid configuration key specified: %s'
|
|
% (',' .join(invalid_keys))
|
|
)
|
|
|
|
response = self.connection.request(
|
|
action='/servers/%s/set' % (node.id), data=json.dumps(kwargs),
|
|
method='POST'
|
|
)
|
|
|
|
return (response.status == httplib.OK and response.body != '')
|
|
|
|
def deploy_node(self, **kwargs):
|
|
"""
|
|
Create a new node, and start deployment.
|
|
|
|
@inherits: :class:`NodeDriver.deploy_node`
|
|
|
|
:keyword enable_root: If true, root password will be set to
|
|
vnc_password (this will enable SSH access)
|
|
and default 'toor' account will be deleted.
|
|
:type enable_root: ``bool``
|
|
"""
|
|
image = kwargs['image']
|
|
vnc_password = kwargs.get('vnc_password', None)
|
|
enable_root = kwargs.get('enable_root', False)
|
|
|
|
if not vnc_password:
|
|
raise ValueError('You need to provide vnc_password argument '
|
|
'if you want to use deployment')
|
|
|
|
if (image in self._standard_drives and
|
|
not self._standard_drives[image]['supports_deployment']):
|
|
raise ValueError('Image %s does not support deployment'
|
|
% (image.id))
|
|
|
|
if enable_root:
|
|
script = ("unset HISTFILE;"
|
|
"echo root:%s | chpasswd;"
|
|
"sed -i '/^toor.*$/d' /etc/passwd /etc/shadow;"
|
|
"history -c") % vnc_password
|
|
root_enable_script = ScriptDeployment(script=script,
|
|
delete=True)
|
|
deploy = kwargs.get('deploy', None)
|
|
if deploy:
|
|
if (isinstance(deploy, ScriptDeployment) or
|
|
isinstance(deploy, SSHKeyDeployment)):
|
|
deployment = MultiStepDeployment([deploy,
|
|
root_enable_script])
|
|
elif isinstance(deploy, MultiStepDeployment):
|
|
deployment = deploy
|
|
deployment.add(root_enable_script)
|
|
else:
|
|
deployment = root_enable_script
|
|
|
|
kwargs['deploy'] = deployment
|
|
|
|
if not kwargs.get('ssh_username', None):
|
|
kwargs['ssh_username'] = 'toor'
|
|
|
|
return super(ElasticStackBaseNodeDriver, self).deploy_node(**kwargs)
|
|
|
|
def ex_shutdown_node(self, node):
|
|
"""
|
|
Sends the ACPI power-down event
|
|
|
|
:param node: Node which should be used
|
|
:type node: :class:`Node`
|
|
|
|
:rtype: ``bool``
|
|
"""
|
|
response = self.connection.request(
|
|
action='/servers/%s/shutdown' % (node.id),
|
|
method='POST'
|
|
)
|
|
return response.status == 204
|
|
|
|
def ex_destroy_drive(self, drive_uuid):
|
|
"""
|
|
Deletes a drive
|
|
|
|
:param drive_uuid: Drive uuid which should be used
|
|
:type drive_uuid: ``str``
|
|
|
|
:rtype: ``bool``
|
|
"""
|
|
response = self.connection.request(
|
|
action='/drives/%s/destroy' % (drive_uuid),
|
|
method='POST'
|
|
)
|
|
return response.status == 204
|
|
|
|
# Helper methods
|
|
def _to_node(self, data, ssh_password=None):
|
|
try:
|
|
state = NODE_STATE_MAP[data['status']]
|
|
except KeyError:
|
|
state = NodeState.UNKNOWN
|
|
|
|
if 'nic:0:dhcp:ip' in data:
|
|
if isinstance(data['nic:0:dhcp:ip'], list):
|
|
public_ip = data['nic:0:dhcp:ip']
|
|
else:
|
|
public_ip = [data['nic:0:dhcp:ip']]
|
|
else:
|
|
public_ip = []
|
|
|
|
extra = {'cpu': data['cpu'],
|
|
'mem': data['mem']}
|
|
|
|
if 'started' in data:
|
|
extra['started'] = data['started']
|
|
|
|
if 'smp' in data:
|
|
extra['smp'] = data['smp']
|
|
|
|
if 'vnc:ip' in data:
|
|
extra['vnc:ip'] = data['vnc:ip']
|
|
|
|
if 'vnc:password' in data:
|
|
extra['vnc:password'] = data['vnc:password']
|
|
|
|
boot_device = data['boot']
|
|
|
|
if isinstance(boot_device, list):
|
|
for device in boot_device:
|
|
extra[device] = data[device]
|
|
else:
|
|
extra[boot_device] = data[boot_device]
|
|
|
|
if ssh_password:
|
|
extra.update({'password': ssh_password})
|
|
|
|
node = Node(id=data['server'], name=data['name'], state=state,
|
|
public_ips=public_ip, private_ips=None,
|
|
driver=self.connection.driver,
|
|
extra=extra)
|
|
|
|
return node
|