| # 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 b, httplib |
| from libcloud.common.base import JsonResponse, ConnectionUserAndKey |
| from libcloud.common.types import InvalidCredsError |
| from libcloud.compute.base import Node, NodeSize, NodeImage, NodeDriver |
| from libcloud.compute.types import NodeState |
| from libcloud.compute.deployment import ScriptDeployment, SSHKeyDeployment, MultiStepDeployment |
| |
| try: |
| import simplejson as json |
| except ImportError: |
| import json |
| |
| |
| 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: {} ({})".format(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("{}:{}".format(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/{}/image/{}/gunzip".format(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().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 |