Source code for libcloud.compute.drivers.vultr

# 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.
"""
Vultr Driver
"""
import time
import json
import base64
from functools import update_wrapper
from typing import Optional, List, Dict, Union, Any

from libcloud.common.base import ConnectionKey, JsonResponse
from libcloud.common.types import InvalidCredsError
from libcloud.common.types import LibcloudError
from libcloud.common.types import ServiceUnavailableError
from libcloud.compute.base import Node, NodeImage, NodeSize, NodeLocation
from libcloud.compute.base import KeyPair, StorageVolume
from libcloud.compute.base import NodeDriver
from libcloud.compute.types import NodeState, StorageVolumeState
from libcloud.compute.types import Provider, VolumeSnapshotState
from libcloud.utils.iso8601 import parse_date
from libcloud.utils.py3 import httplib
from libcloud.utils.py3 import urlencode
from libcloud.utils.publickey import get_pubkey_openssh_fingerprint
from libcloud.common.vultr import DEFAULT_API_VERSION
from libcloud.common.vultr import VultrConnectionV2
from libcloud.common.vultr import VultrNetwork, VultrNodeSnapshot

# For matching region by id
VULTR_COMPUTE_INSTANCE_LOCATIONS = {
    "1": {
        "DCID": "1",
        "name": "New Jersey",
        "country": "US",
        "continent": "North America",
        "state": "NJ",
        "regioncode": "EWR",
    },
    "2": {
        "DCID": "2",
        "name": "Chicago",
        "country": "US",
        "continent": "North America",
        "state": "IL",
        "regioncode": "ORD",
    },
    "3": {
        "DCID": "3",
        "name": "Dallas",
        "country": "US",
        "continent": "North America",
        "state": "TX",
        "regioncode": "DFW",
    },
    "4": {
        "DCID": "4",
        "name": "Seattle",
        "country": "US",
        "continent": "North America",
        "state": "WA",
        "regioncode": "SEA",
    },
    "5": {
        "DCID": "5",
        "name": "Los Angeles",
        "country": "US",
        "continent": "North America",
        "state": "CA",
        "regioncode": "LAX",
    },
    "6": {
        "DCID": "6",
        "name": "Atlanta",
        "country": "US",
        "continent": "North America",
        "state": "GA",
        "regioncode": "ATL",
    },
    "7": {
        "DCID": "7",
        "name": "Amsterdam",
        "country": "NL",
        "continent": "Europe",
        "state": "",
        "regioncode": "AMS",
    },
    "8": {
        "DCID": "8",
        "name": "London",
        "country": "GB",
        "continent": "Europe",
        "state": "",
        "regioncode": "LHR",
    },
    "9": {
        "DCID": "9",
        "name": "Frankfurt",
        "country": "DE",
        "continent": "Europe",
        "state": "",
        "regioncode": "FRA",
    },
    "12": {
        "DCID": "12",
        "name": "Silicon Valley",
        "country": "US",
        "continent": "North America",
        "state": "CA",
        "regioncode": "SJC",
    },
    "19": {
        "DCID": "19",
        "name": "Sydney",
        "country": "AU",
        "continent": "Australia",
        "state": "",
        "regioncode": "SYD",
    },
    "22": {
        "DCID": "22",
        "name": "Toronto",
        "country": "CA",
        "continent": "North America",
        "state": "",
        "regioncode": "YTO",
    },
    "24": {
        "DCID": "24",
        "name": "Paris",
        "country": "FR",
        "continent": "Europe",
        "state": "",
        "regioncode": "CDG",
    },
    "25": {
        "DCID": "25",
        "name": "Tokyo",
        "country": "JP",
        "continent": "Asia",
        "state": "",
        "regioncode": "NRT",
    },
    "34": {
        "DCID": "34",
        "name": "Seoul",
        "country": "KR",
        "continent": "Asia",
        "state": "",
        "regioncode": "ICN",
    },
    "39": {
        "DCID": "39",
        "name": "Miami",
        "country": "US",
        "continent": "North America",
        "state": "FL",
        "regioncode": "MIA",
    },
    "40": {
        "DCID": "40",
        "name": "Singapore",
        "country": "SG",
        "continent": "Asia",
        "state": "",
        "regioncode": "SGP",
    },
}
# For matching image by id
VULTR_COMPUTE_INSTANCE_IMAGES = {
    "127": {
        "OSID": 127,
        "name": "CentOS 6 x64",
        "arch": "x64",
        "family": "centos",
        "windows": False,
    },
    "147": {
        "OSID": 147,
        "name": "CentOS 6 i386",
        "arch": "i386",
        "family": "centos",
        "windows": False,
    },
    "167": {
        "OSID": 167,
        "name": "CentOS 7 x64",
        "arch": "x64",
        "family": "centos",
        "windows": False,
    },
    "381": {
        "OSID": 381,
        "name": "CentOS 7 SELinux x64",
        "arch": "x64",
        "family": "centos",
        "windows": False,
    },
    "362": {
        "OSID": 362,
        "name": "CentOS 8 x64",
        "arch": "x64",
        "family": "centos",
        "windows": False,
    },
    "401": {
        "OSID": 401,
        "name": "CentOS 8 Stream x64",
        "arch": "x64",
        "family": "centos",
        "windows": False,
    },
    "215": {
        "OSID": 215,
        "name": "Ubuntu 16.04 x64",
        "arch": "x64",
        "family": "ubuntu",
        "windows": False,
    },
    "216": {
        "OSID": 216,
        "name": "Ubuntu 16.04 i386",
        "arch": "i386",
        "family": "ubuntu",
        "windows": False,
    },
    "270": {
        "OSID": 270,
        "name": "Ubuntu 18.04 x64",
        "arch": "x64",
        "family": "ubuntu",
        "windows": False,
    },
    "387": {
        "OSID": 387,
        "name": "Ubuntu 20.04 x64",
        "arch": "x64",
        "family": "ubuntu",
        "windows": False,
    },
    "194": {
        "OSID": 194,
        "name": "Debian 8 i386 (jessie)",
        "arch": "i386",
        "family": "debian",
        "windows": False,
    },
    "244": {
        "OSID": 244,
        "name": "Debian 9 x64 (stretch)",
        "arch": "x64",
        "family": "debian",
        "windows": False,
    },
    "352": {
        "OSID": 352,
        "name": "Debian 10 x64 (buster)",
        "arch": "x64",
        "family": "debian",
        "windows": False,
    },
    "230": {
        "OSID": 230,
        "name": "FreeBSD 11 x64",
        "arch": "x64",
        "family": "freebsd",
        "windows": False,
    },
    "327": {
        "OSID": 327,
        "name": "FreeBSD 12 x64",
        "arch": "x64",
        "family": "freebsd",
        "windows": False,
    },
    "366": {
        "OSID": 366,
        "name": "OpenBSD 6.6 x64",
        "arch": "x64",
        "family": "openbsd",
        "windows": False,
    },
    "394": {
        "OSID": 394,
        "name": "OpenBSD 6.7 x64",
        "arch": "x64",
        "family": "openbsd",
        "windows": False,
    },
    "391": {
        "OSID": 391,
        "name": "Fedora CoreOS",
        "arch": "x64",
        "family": "fedora-coreos",
        "windows": False,
    },
    "367": {
        "OSID": 367,
        "name": "Fedora 31 x64",
        "arch": "x64",
        "family": "fedora",
        "windows": False,
    },
    "389": {
        "OSID": 389,
        "name": "Fedora 32 x64",
        "arch": "x64",
        "family": "fedora",
        "windows": False,
    },
    "124": {
        "OSID": 124,
        "name": "Windows 2012 R2 x64",
        "arch": "x64",
        "family": "windows",
        "windows": False,
    },
    "240": {
        "OSID": 240,
        "name": "Windows 2016 x64",
        "arch": "x64",
        "family": "windows",
        "windows": False,
    },
    "159": {
        "OSID": 159,
        "name": "Custom",
        "arch": "x64",
        "family": "iso",
        "windows": False,
    },
    "164": {
        "OSID": 164,
        "name": "Snapshot",
        "arch": "x64",
        "family": "snapshot",
        "windows": False,
    },
    "180": {
        "OSID": 180,
        "name": "Backup",
        "arch": "x64",
        "family": "backup",
        "windows": False,
    },
    "186": {
        "OSID": 186,
        "name": "Application",
        "arch": "x64",
        "family": "application",
        "windows": False,
    },
}
VULTR_COMPUTE_INSTANCE_SIZES = {
    "201": {
        "VPSPLANID": "201",
        "name": "1024 MB RAM,25 GB SSD,1.00 TB BW",
        "vcpu_count": "1",
        "ram": "1024",
        "disk": "25",
        "bandwidth": "1.00",
        "bandwidth_gb": "1024",
        "price_per_month": "5.00",
        "plan_type": "SSD",
        "windows": False,
    },
    "202": {
        "VPSPLANID": "202",
        "name": "2048 MB RAM,55 GB SSD,2.00 TB BW",
        "vcpu_count": "1",
        "ram": "2048",
        "disk": "55",
        "bandwidth": "2.00",
        "bandwidth_gb": "2048",
        "price_per_month": "10.00",
        "plan_type": "SSD",
        "windows": False,
    },
    "203": {
        "VPSPLANID": "203",
        "name": "4096 MB RAM,80 GB SSD,3.00 TB BW",
        "vcpu_count": "2",
        "ram": "4096",
        "disk": "80",
        "bandwidth": "3.00",
        "bandwidth_gb": "3072",
        "price_per_month": "20.00",
        "plan_type": "SSD",
        "windows": False,
    },
    "204": {
        "VPSPLANID": "204",
        "name": "8192 MB RAM,160 GB SSD,4.00 TB BW",
        "vcpu_count": "4",
        "ram": "8192",
        "disk": "160",
        "bandwidth": "4.00",
        "bandwidth_gb": "4096",
        "price_per_month": "40.00",
        "plan_type": "SSD",
        "windows": False,
    },
    "205": {
        "VPSPLANID": "205",
        "name": "16384 MB RAM,320 GB SSD,5.00 TB BW",
        "vcpu_count": "6",
        "ram": "16384",
        "disk": "320",
        "bandwidth": "5.00",
        "bandwidth_gb": "5120",
        "price_per_month": "80.00",
        "plan_type": "SSD",
        "windows": False,
    },
    "206": {
        "VPSPLANID": "206",
        "name": "32768 MB RAM,640 GB SSD,6.00 TB BW",
        "vcpu_count": "8",
        "ram": "32768",
        "disk": "640",
        "bandwidth": "6.00",
        "bandwidth_gb": "6144",
        "price_per_month": "160.00",
        "plan_type": "SSD",
        "windows": False,
    },
    "207": {
        "VPSPLANID": "207",
        "name": "65536 MB RAM,1280 GB SSD,10.00 TB BW",
        "vcpu_count": "16",
        "ram": "65536",
        "disk": "1280",
        "bandwidth": "10.00",
        "bandwidth_gb": "10240",
        "price_per_month": "320.00",
        "plan_type": "SSD",
        "windows": False,
    },
    "208": {
        "VPSPLANID": "208",
        "name": "98304 MB RAM,1600 GB SSD,15.00 TB BW",
        "vcpu_count": "24",
        "ram": "98304",
        "disk": "1600",
        "bandwidth": "15.00",
        "bandwidth_gb": "15360",
        "price_per_month": "640.00",
        "plan_type": "SSD",
        "windows": False,
    },
    "115": {
        "VPSPLANID": "115",
        "name": "8192 MB RAM,110 GB SSD,10.00 TB BW",
        "vcpu_count": "2",
        "ram": "8192",
        "disk": "110",
        "bandwidth": "10.00",
        "bandwidth_gb": "10240",
        "price_per_month": "60.00",
        "plan_type": "DEDICATED",
        "windows": False,
    },
    "116": {
        "VPSPLANID": "116",
        "name": "16384 MB RAM,2x110 GB SSD,20.00 TB BW",
        "vcpu_count": "4",
        "ram": "16384",
        "disk": "110",
        "bandwidth": "20.00",
        "bandwidth_gb": "20480",
        "price_per_month": "120.00",
        "plan_type": "DEDICATED",
        "windows": False,
    },
    "117": {
        "VPSPLANID": "117",
        "name": "24576 MB RAM,3x110 GB SSD,30.00 TB BW",
        "vcpu_count": "6",
        "ram": "24576",
        "disk": "110",
        "bandwidth": "30.00",
        "bandwidth_gb": "30720",
        "price_per_month": "180.00",
        "plan_type": "DEDICATED",
        "windows": False,
    },
    "118": {
        "VPSPLANID": "118",
        "name": "32768 MB RAM,4x110 GB SSD,40.00 TB BW",
        "vcpu_count": "8",
        "ram": "32768",
        "disk": "110",
        "bandwidth": "40.00",
        "bandwidth_gb": "40960",
        "price_per_month": "240.00",
        "plan_type": "DEDICATED",
        "windows": False,
    },
    "400": {
        "VPSPLANID": "400",
        "name": "1024 MB RAM,32 GB SSD,1.00 TB BW",
        "vcpu_count": "1",
        "ram": "1024",
        "disk": "32",
        "bandwidth": "1.00",
        "bandwidth_gb": "1024",
        "price_per_month": "6.00",
        "plan_type": "HIGHFREQUENCY",
        "windows": False,
    },
    "401": {
        "VPSPLANID": "401",
        "name": "2048 MB RAM,64 GB SSD,2.00 TB BW",
        "vcpu_count": "1",
        "ram": "2048",
        "disk": "64",
        "bandwidth": "2.00",
        "bandwidth_gb": "2048",
        "price_per_month": "12.00",
        "plan_type": "HIGHFREQUENCY",
        "windows": False,
    },
    "402": {
        "VPSPLANID": "402",
        "name": "4096 MB RAM,128 GB SSD,3.00 TB BW",
        "vcpu_count": "2",
        "ram": "4096",
        "disk": "128",
        "bandwidth": "3.00",
        "bandwidth_gb": "3072",
        "price_per_month": "24.00",
        "plan_type": "HIGHFREQUENCY",
        "windows": False,
    },
    "403": {
        "VPSPLANID": "403",
        "name": "8192 MB RAM,256 GB SSD,4.00 TB BW",
        "vcpu_count": "3",
        "ram": "8192",
        "disk": "256",
        "bandwidth": "4.00",
        "bandwidth_gb": "4096",
        "price_per_month": "48.00",
        "plan_type": "HIGHFREQUENCY",
        "windows": False,
    },
    "404": {
        "VPSPLANID": "404",
        "name": "16384 MB RAM,384 GB SSD,5.00 TB BW",
        "vcpu_count": "4",
        "ram": "16384",
        "disk": "384",
        "bandwidth": "5.00",
        "bandwidth_gb": "5120",
        "price_per_month": "96.00",
        "plan_type": "HIGHFREQUENCY",
        "windows": False,
    },
    "405": {
        "VPSPLANID": "405",
        "name": "32768 MB RAM,512 GB SSD,6.00 TB BW",
        "vcpu_count": "8",
        "ram": "32768",
        "disk": "512",
        "bandwidth": "6.00",
        "bandwidth_gb": "6144",
        "price_per_month": "192.00",
        "plan_type": "HIGHFREQUENCY",
        "windows": False,
    },
    "406": {
        "VPSPLANID": "406",
        "name": "49152 MB RAM,768 GB SSD,8.00 TB BW",
        "vcpu_count": "12",
        "ram": "49152",
        "disk": "768",
        "bandwidth": "8.00",
        "bandwidth_gb": "8192",
        "price_per_month": "256.00",
        "plan_type": "HIGHFREQUENCY",
        "windows": False,
    },
}


[docs]class rate_limited: """ Decorator for retrying Vultr calls that are rate-limited. :param int sleep: Seconds to sleep after being rate-limited. :param int retries: Number of retries. """ def __init__(self, sleep=0.5, retries=1): self.sleep = sleep self.retries = retries def __call__(self, call): """ Run ``call`` method until it's not rate-limited. The method is invoked while it returns 503 Service Unavailable or the allowed number of retries is reached. :param callable call: Method to be decorated. """ def wrapper(*args, **kwargs): last_exception = None for _ in range(self.retries + 1): try: return call(*args, **kwargs) except ServiceUnavailableError as e: last_exception = e time.sleep(self.sleep) # hit by rate limit, let's sleep if last_exception: raise last_exception # pylint: disable=raising-bad-type update_wrapper(wrapper, call) return wrapper
[docs]class VultrResponse(JsonResponse):
[docs] def parse_error(self): if self.status == httplib.OK: body = self.parse_body() return body elif self.status == httplib.FORBIDDEN: raise InvalidCredsError(self.body) elif self.status == httplib.SERVICE_UNAVAILABLE: raise ServiceUnavailableError(self.body) else: raise LibcloudError(self.body)
[docs]class SSHKey(object): 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 VultrConnection(ConnectionKey): """ Connection class for the Vultr driver. """ host = "api.vultr.com" responseCls = VultrResponse unauthenticated_endpoints = { # {action: methods} "/v1/app/list": ["GET"], "/v1/os/list": ["GET"], "/v1/plans/list": ["GET"], "/v1/plans/list_vc2": ["GET"], "/v1/plans/list_vdc2": ["GET"], "/v1/regions/availability": ["GET"], "/v1/regions/list": ["GET"], }
[docs] def add_default_headers(self, headers): """ Adds ``API-Key`` default header. :return: Updated headers. :rtype: dict """ if self.require_api_key(): headers.update({"API-Key": self.key}) return headers
[docs] def encode_data(self, data): return urlencode(data)
[docs] @rate_limited() def get(self, url): return self.request(url)
[docs] @rate_limited() def post(self, url, data): headers = {"Content-Type": "application/x-www-form-urlencoded"} return self.request(url, data=data, headers=headers, method="POST")
[docs] def require_api_key(self): """ Check whether this call (method + action) must be authenticated. :return: True if ``API-Key`` header required, False otherwise. :rtype: bool """ try: return self.method not in self.unauthenticated_endpoints[self.action] except KeyError: return True
[docs]class VultrNodeDriverHelper(object): """ VultrNode helper class. """
[docs] def handle_extra(self, extra_keys, data): extra = {} for key in extra_keys: if key in data: extra[key] = data[key] return extra
[docs]class VultrNodeDriver(NodeDriver): type = Provider.VULTR name = "Vultr" website = "https://www.vultr.com" def __new__( cls, key, secret=None, secure=True, host=None, port=None, api_version=DEFAULT_API_VERSION, region=None, **kwargs, ): if cls is VultrNodeDriver: if api_version == "1": cls = VultrNodeDriverV1 elif api_version == "2": cls = VultrNodeDriverV2 else: raise NotImplementedError( "No Vultr driver found for API version: %s" % (api_version) ) return super().__new__(cls)
[docs]class VultrNodeDriverV1(VultrNodeDriver): """ VultrNode node driver. """ connectionCls = VultrConnection NODE_STATE_MAP = {"pending": NodeState.PENDING, "active": NodeState.RUNNING} EX_CREATE_YES_NO_ATTRIBUTES = [ "enable_ipv6", "enable_private_network", "auto_backups", "notify_activate", "ddos_protection", ] EX_CREATE_ID_ATTRIBUTES = { "iso_id": "ISOID", "script_id": "SCRIPTID", "snapshot_id": "SNAPSHOTID", "app_id": "APPID", } EX_CREATE_ATTRIBUTES = [ "ipxe_chain_url", "label", "userdata", "reserved_ip_v4", "hostname", "tag", ] EX_CREATE_ATTRIBUTES.extend(EX_CREATE_YES_NO_ATTRIBUTES) EX_CREATE_ATTRIBUTES.extend(EX_CREATE_ID_ATTRIBUTES.keys()) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._helper = VultrNodeDriverHelper()
[docs] def list_nodes(self): return self._list_resources("/v1/server/list", self._to_node)
[docs] def list_key_pairs(self): """ List all the available SSH keys. :return: Available SSH keys. :rtype: ``list`` of :class:`SSHKey` """ return self._list_resources("/v1/sshkey/list", self._to_ssh_key)
[docs] def create_key_pair(self, name, public_key=""): """ Create a new SSH key. :param name: Name of the new SSH key :type name: ``str`` :key public_key: Public part of the new SSH key :type name: ``str`` :return: True on success :rtype: ``bool`` """ params = {"name": name, "ssh_key": public_key} res = self.connection.post("/v1/sshkey/create", params) return res.status == httplib.OK
[docs] def delete_key_pair(self, key_pair): """ Delete an SSH key. :param key_pair: The SSH key to delete :type key_pair: :class:`SSHKey` :return: True on success :rtype: ``bool`` """ params = {"SSHKEYID": key_pair.id} res = self.connection.post("/v1/sshkey/destroy", params) return res.status == httplib.OK
[docs] def list_locations(self): return self._list_resources("/v1/regions/list", self._to_location)
[docs] def list_sizes(self): return self._list_resources("/v1/plans/list", self._to_size)
[docs] def list_images(self): return self._list_resources("/v1/os/list", self._to_image)
# pylint: disable=too-many-locals
[docs] def create_node( self, name, size, image, location, ex_ssh_key_ids=None, ex_create_attr=None ): """ Create a node :param name: Name for the new node :type name: ``str`` :param size: Size of the new node :type size: :class:`NodeSize` :param image: Image for the new node :type image: :class:`NodeImage` :param location: Location of the new node :type location: :class:`NodeLocation` :param ex_ssh_key_ids: IDs of the SSH keys to initialize :type ex_sshkeyid: ``list`` of ``str`` :param ex_create_attr: Extra attributes for node creation :type ex_create_attr: ``dict`` The `ex_create_attr` parameter can include the following dictionary key and value pairs: * `ipxe_chain_url`: ``str`` for specifying URL to boot via IPXE * `iso_id`: ``str`` the ID of a specific ISO to mount, only meaningful with the `Custom` `NodeImage` * `script_id`: ``int`` ID of a startup script to execute on boot, only meaningful when the `NodeImage` is not `Custom` * 'snapshot_id`: ``str`` Snapshot ID to restore for the initial installation, only meaningful with the `Snapshot` `NodeImage` * `enable_ipv6`: ``bool`` Whether an IPv6 subnet should be assigned * `enable_private_network`: ``bool`` Whether private networking support should be added * `label`: ``str`` Text label to be shown in the control panel * `auto_backups`: ``bool`` Whether automatic backups should be enabled * `app_id`: ``int`` App ID to launch if launching an application, only meaningful when the `NodeImage` is `Application` * `userdata`: ``str`` Base64 encoded cloud-init user-data * `notify_activate`: ``bool`` Whether an activation email should be sent when the server is ready * `ddos_protection`: ``bool`` Whether DDOS protection should be enabled * `reserved_ip_v4`: ``str`` IP address of the floating IP to use as the main IP of this server * `hostname`: ``str`` The hostname to assign to this server * `tag`: ``str`` The tag to assign to this server :return: The newly created node. :rtype: :class:`Node` """ params = { "DCID": location.id, "VPSPLANID": size.id, "OSID": image.id, "label": name, } if ex_ssh_key_ids is not None: params["SSHKEYID"] = ",".join(ex_ssh_key_ids) ex_create_attr = ex_create_attr or {} for key, value in ex_create_attr.items(): if key in self.EX_CREATE_ATTRIBUTES: if key in self.EX_CREATE_YES_NO_ATTRIBUTES: params[key] = "yes" if value else "no" else: if key in self.EX_CREATE_ID_ATTRIBUTES: key = self.EX_CREATE_ID_ATTRIBUTES[key] params[key] = value result = self.connection.post("/v1/server/create", params) if result.status != httplib.OK: return False subid = result.object["SUBID"] retry_count = 3 created_node = None for _ in range(retry_count): try: nodes = self.list_nodes() created_node = [n for n in nodes if n.id == subid][0] except IndexError: time.sleep(1) else: break return created_node
[docs] def reboot_node(self, node): params = {"SUBID": node.id} res = self.connection.post("/v1/server/reboot", params) return res.status == httplib.OK
[docs] def destroy_node(self, node): params = {"SUBID": node.id} res = self.connection.post("/v1/server/destroy", params) return res.status == httplib.OK
def _list_resources(self, url, tranform_func): data = self.connection.get(url).object sorted_key = sorted(data) return [tranform_func(data[key]) for key in sorted_key] def _to_node(self, data): if "status" in data: state = self.NODE_STATE_MAP.get(data["status"], NodeState.UNKNOWN) if state == NodeState.RUNNING and data["power_status"] != "running": state = NodeState.STOPPED else: state = NodeState.UNKNOWN if "main_ip" in data and data["main_ip"] is not None: public_ips = [data["main_ip"]] else: public_ips = [] # simple check that we have ip address in value if len(data["internal_ip"]) > 0: private_ips = [data["internal_ip"]] else: private_ips = [] created_at = parse_date(data["date_created"]) # response ordering extra_keys = [ "location", # Location name "default_password", "pending_charges", "cost_per_month", "current_bandwidth_gb", "allowed_bandwidth_gb", "netmask_v4", "gateway_v4", "power_status", "server_state", "v6_networks", # TODO: Does we really need kvm_url? "kvm_url", "auto_backups", "tag", # "OSID", # Operating system to use. See v1/os/list. "APPID", "FIREWALLGROUPID", ] extra = self._helper.handle_extra(extra_keys, data) resolve_data = VULTR_COMPUTE_INSTANCE_IMAGES.get(data["OSID"]) if resolve_data: image = self._to_image(resolve_data) else: image = None resolve_data = VULTR_COMPUTE_INSTANCE_SIZES.get(data["VPSPLANID"]) if resolve_data: size = self._to_size(resolve_data) else: size = None # resolve_data = VULTR_COMPUTE_INSTANCE_LOCATIONS.get(data['DCID']) # if resolve_data: # location = self._to_location(resolve_data) # extra['location'] = location node = Node( id=data["SUBID"], name=data["label"], state=state, public_ips=public_ips, private_ips=private_ips, image=image, size=size, extra=extra, created_at=created_at, driver=self, ) return node def _to_location(self, data): extra_keys = [ "continent", "state", "ddos_protection", "block_storage", "regioncode", ] extra = self._helper.handle_extra(extra_keys, data) return NodeLocation( id=data["DCID"], name=data["name"], country=data["country"], extra=extra, driver=self, ) def _to_size(self, data): extra_keys = [ "vcpu_count", "plan_type", "available_locations", ] extra = self._helper.handle_extra(extra_keys, data) # backward compatibility if extra.get("vcpu_count").isdigit(): extra["vcpu_count"] = int(extra["vcpu_count"]) ram = int(data["ram"]) disk = int(data["disk"]) # NodeSize accepted int instead float bandwidth = int(float(data["bandwidth"])) price = float(data["price_per_month"]) return NodeSize( id=data["VPSPLANID"], name=data["name"], ram=ram, disk=disk, bandwidth=bandwidth, price=price, extra=extra, driver=self, ) def _to_image(self, data): extra_keys = ["arch", "family"] extra = self._helper.handle_extra(extra_keys, data) return NodeImage(id=data["OSID"], name=data["name"], extra=extra, driver=self) def _to_ssh_key(self, data): return SSHKey(id=data["SSHKEYID"], name=data["name"], pub_key=data["ssh_key"])
[docs]class VultrNodeDriverV2(VultrNodeDriver): """ Vultr API v2 NodeDriver. """ connectionCls = VultrConnectionV2 NODE_STATE_MAP = { "active": NodeState.RUNNING, "halted": NodeState.STOPPED, "rebooting": NodeState.REBOOTING, "resizing": NodeState.RECONFIGURING, "pending": NodeState.PENDING, } VOLUME_STATE_MAP = { "active": StorageVolumeState.AVAILABLE, "pending": StorageVolumeState.CREATING, } SNAPSHOT_STATE_MAP = { "complete": VolumeSnapshotState.AVAILABLE, "pending": VolumeSnapshotState.CREATING, }
[docs] def list_nodes(self, ex_list_bare_metals: bool = True) -> List[Node]: """List all nodes. :keyword ex_list_bare_metals: Whether to fetch bare metal nodes. :type ex_list_bare_metals: ``bool`` :return: list of node objects :rtype: ``list`` of :class: `Node` """ data = self._paginated_request("/v2/instances", "instances") nodes = [self._to_node(item) for item in data] if ex_list_bare_metals: nodes += self.ex_list_bare_metal_nodes() return nodes
[docs] def create_node( self, name: str, size: NodeSize, location: NodeLocation, image: Optional[NodeImage] = None, ex_ssh_key_ids: Optional[List[str]] = None, ex_private_network_ids: Optional[List[str]] = None, ex_snapshot: Union[VultrNodeSnapshot, str, None] = None, ex_enable_ipv6: bool = False, ex_backups: bool = False, ex_userdata: Optional[str] = None, ex_ddos_protection: bool = False, ex_enable_private_network: bool = False, ex_ipxe_chain_url: Optional[str] = None, ex_iso_id: Optional[str] = None, ex_script_id: Optional[str] = None, ex_image_id: Optional[str] = None, ex_activation_email: bool = False, ex_hostname: Optional[str] = None, ex_tag: Optional[str] = None, ex_firewall_group_id: Optional[str] = None, ex_reserved_ipv4: Optional[str] = None, ex_persistent_pxe: bool = False, ) -> Node: """Create a new node. :param name: The new node's name. :type name: ``str`` :param size: The size to use to create the node. :type size: :class: `NodeSize` :param location: The location to provision the node. :type location: :class: `NodeLocation` :keyword image: The image to use to provision the node. :type image: :class: `NodeImage` :keyword ex_ssh_key_ids: List of SSH keys to install on this node. :type ex_ssh_key_ids: ``list`` of ``str`` :keyword ex_private_network_ids: The network ids to attach to node. This parameter takes precedence over ex_enable_private_network (VPS only) :type ex_private_network_ids: ``list`` of ``str`` :keyword ex_snapshot: The snapshot to use when deploying the node. Mutually exclusive with image, :type ex_snapshot: :class: `VultrNodeSnapshot` or ``str`` :keyword ex_enable_ipv6: Wheteher to enable IPv6. :type ex_enable_ipv6: ``bool`` :keyword ex_backups: Enable automatic backups for the node. (VPS only) :type ex_backups: ``bool`` :keyword ex_userdata: String containing user data :type ex_userdata: ``str`` :keyword ex_ddos_protection: Enable DDoS protection (VPS only) :type ex_ddos_protection: ``bool`` :keyword ex_enable_private_network: Enable private networking. Mutually exclusive with ex_private_network_ids. (VPS only) :type ex_enable_private_network: ``bool`` :keyword ex_ipxe_chain_url: The URL location of the iPXE chainloader (VPS only) :type ex_ipxe_chain_url: ``str`` :keyword ex_iso_id: The ISO id to use when deploying this node. (VPS only) :type ex_iso_id: ``str`` :keyword ex_script_id: The startup script id to use when deploying this node. :type ex_script_id: ``str`` :keyword ex_image_id: The Application image_id to use when deploying this node. :type ex_image_id: ``str`` :keyword ex_activation_email: Notify by email after deployment. :type ex_activation_email: ``bool`` :keyword ex_hostname: The hostname to use when deploying this node. :type ex_hostname: ``str`` :keyword ex_tag: The user-supplied tag. :type ex_tag: ``str`` :keyword ex_firewall_group_id: The Firewall Group id to attach to this node. (VPS only) :type ex_firewall_group_id: ``str`` :keyword ex_reserved_ipv4: Id of the floating IP to use as the main IP of this node. :type ex_reserved_ipv4: ``str`` :keyword ex_persistent_pxe: Enable persistent PXE (Bare Metal only) :type ex_persistent_pxe: ``bool`` """ data = { "label": name, "region": location.id, "plan": size.id, "enable_ipv6": ex_enable_ipv6, "activation_email": ex_activation_email, } if image: data["os_id"] = image.id if ex_ssh_key_ids: data["sshkey_id"] = ex_ssh_key_ids if ex_snapshot: try: data["snapshot_id"] = ex_snapshot.id except AttributeError: data["snapshot_id"] = ex_snapshot if ex_userdata: data["user_data"] = base64.b64encode(bytes(ex_userdata, "utf-8")).decode( "utf-8" ) if ex_script_id: data["script_id"] = ex_script_id if ex_image_id: data["image_id"] = ex_image_id if ex_hostname: data["hostname"] = ex_hostname if ex_reserved_ipv4: data["reserved_ipv4"] = ex_reserved_ipv4 if ex_tag: data["tag"] = ex_tag if self._is_bare_metal(size): if ex_persistent_pxe: data["persistent_pxe"] = ex_persistent_pxe resp = self.connection.request( "/v2/bare-metals", data=json.dumps(data), method="POST" ) return self._to_node(resp.object["bare_metal"]) else: if ex_private_network_ids: data["attach_private_network"] = ex_private_network_ids if ex_enable_private_network: data["enable_private_network"] = ex_enable_private_network if ex_ipxe_chain_url: data["ipxe_chain_url"] = ex_ipxe_chain_url if ex_iso_id: data["iso_id"] = ex_iso_id if ex_ddos_protection: data["ddos_protection"] = ex_ddos_protection if ex_firewall_group_id: data["firewall_group_id"] = ex_firewall_group_id if ex_backups: data["backups"] = "enabled" if ex_backups is True else "disabled" resp = self.connection.request( "/v2/instances", data=json.dumps(data), method="POST" ) return self._to_node(resp.object["instance"])
[docs] def reboot_node(self, node: Node) -> bool: """Reboot the given node. :param node: The node to be rebooted. :type node: :class: `Node` :rtype: ``bool`` """ if self._is_bare_metal(node.size): return self.ex_reboot_bare_metal_node(node) resp = self.connection.request( "/v2/instances/%s/reboot" % node.id, method="POST" ) return resp.success()
[docs] def start_node(self, node: Node) -> bool: """Start the given node. :param node: The node to be started. :type node: :class: `Node` :rtype: ``bool`` """ if self._is_bare_metal(node.size): return self.ex_start_bare_metal_node(node) resp = self.connection.request( "/v2/instances/%s/start" % node.id, method="POST" ) return resp.success()
[docs] def stop_node(self, node: Node) -> bool: """Stop the given node. :param node: The node to be stopped. :type node: :class: `Node` :rtype: ``bool`` """ if self._is_bare_metal(node.size): return self.ex_stop_bare_metal_node(node) return self.ex_stop_nodes([node])
[docs] def destroy_node(self, node: Node) -> bool: """Destroy the given node. :param node: The node to be destroyed. :type node: :class: `Node` :rtype: ``bool`` """ if self._is_bare_metal(node.size): return self.ex_destroy_bare_metal_node(node) resp = self.connection.request("/v2/instances/%s" % node.id, method="DELETE") return resp.success()
[docs] def list_sizes(self, ex_list_bare_metals: bool = True) -> List[NodeSize]: """List available node sizes. :keyword ex_list_bare_metals: Whether to fetch bare metal sizes. :type ex_list_bare_metals: ``bool`` :rtype: ``list`` of :class: `NodeSize` """ data = self._paginated_request("/v2/plans", "plans") sizes = [self._to_size(item) for item in data] if ex_list_bare_metals: sizes += self.ex_list_bare_metal_sizes() return sizes
[docs] def list_images(self) -> List[NodeImage]: """List available node images. :rtype: ``list`` of :class: `NodeImage` """ data = self._paginated_request("/v2/os", "os") return [self._to_image(item) for item in data]
[docs] def list_locations(self) -> List[NodeLocation]: """List available node locations. :rtype: ``list`` of :class: `NodeLocation` """ data = self._paginated_request("/v2/regions", "regions") return [self._to_location(item) for item in data]
[docs] def list_volumes(self) -> List[StorageVolume]: """List storage volumes. :rtype: ``list`` of :class:`StorageVolume` """ data = self._paginated_request("/v2/blocks", "blocks") return [self._to_volume(item) for item in data]
[docs] def create_volume( self, size: int, name: str, location: Union[NodeLocation, str], ) -> StorageVolume: """Create a new volume. :param size: Size of the volume in gigabytes.\ Size may range between 10 and 10000. :type size: ``int`` :param name: Name of the volume to be created. :type name: ``str`` :param location: Which data center to create the volume in. :type location: :class:`NodeLocation` or ``str`` :return: The newly created volume. :rtype: :class:`StorageVolume` """ data = { "label": name, "size_gb": size, } try: data["region"] = location.id except AttributeError: data["region"] = location resp = self.connection.request( "/v2/blocks", data=json.dumps(data), method="POST" ) return self._to_volume(resp.object["block"])
[docs] def attach_volume( self, node: Node, volume: StorageVolume, ex_live: bool = True, ) -> bool: """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 ex_live: Attach the volume without restarting the node. :type ex_live: ``bool`` :rytpe: ``bool`` """ data = { "instance_id": node.id, "live": ex_live, } resp = self.connection.request( "/v2/blocks/%s/attach" % volume.id, data=json.dumps(data), method="POST" ) return resp.success()
[docs] def detach_volume( self, volume: StorageVolume, ex_live: bool = True, ) -> bool: """Detaches a volume from a node. :param volume: Volume to be detached :type volume: :class:`StorageVolume` :param ex_live: Detach the volume without restarting the node. :type ex_live: ``bool`` :rtype: ``bool`` """ data = {"live": ex_live} resp = self.connection.request( "/v2/blocks/%s/detach" % volume.id, data=json.dumps(data), method="POST" ) return resp.success()
[docs] def destroy_volume(self, volume: StorageVolume) -> bool: """Destroys a storage volume. :param volume: Volume to be destroyed :type volume: :class:`StorageVolume` :rtype: ``bool`` """ resp = self.connection.request("/v2/blocks/%s" % volume.id, method="DELETE") return resp.success()
[docs] def list_key_pairs(self) -> List[KeyPair]: """List all the available SSH key pair objects. :rtype: ``list`` of :class:`KeyPair` """ data = self._paginated_request("/v2/ssh-keys", "ssh_keys") return [self._to_key_pair(item) for item in data]
[docs] def get_key_pair(self, key_id: str) -> KeyPair: """Retrieve a single key pair. :param key_id: ID of the key pair to retrieve. :type key_id: ``str`` :rtype: :class: `KeyPair` """ resp = self.connection.request("/v2/ssh-keys/%s" % key_id) return self._to_key_pair(resp.object["ssh_key"])
[docs] def import_key_pair_from_string(self, name: str, key_material: str) -> KeyPair: """Import a new public key from string. :param name: Key pair name. :type name: ``str`` :param key_material: Public key material. :type key_material: ``str`` :rtype: :class: `KeyPair` """ data = { "name": name, "ssh_key": key_material, } resp = self.connection.request( "/v2/ssh-keys", data=json.dumps(data), method="POST" ) return self._to_key_pair(resp.object["ssh_key"])
[docs] def delete_key_pair(self, key_pair: KeyPair) -> bool: """Delete existing key pair. :param key_pair: The key pair object to delete. :type key_pair: :class:`.KeyPair` :rtype: ``bool`` """ resp = self.connection.request( "/v2/ssh-keys/%s" % key_pair.extra["id"], method="DELETE" ) return resp.success()
[docs] def ex_list_bare_metal_nodes(self) -> List[Node]: """List all bare metal nodes. :return: list of node objects :rtype: ``list`` of :class: `Node` """ data = self._paginated_request("/v2/bare-metals", "bare_metals") return [self._to_node(item) for item in data]
[docs] def ex_reboot_bare_metal_node(self, node: Node) -> bool: """Reboot the given bare metal node. :param node: The bare metal node to be rebooted. :type node: :class: `Node` :rtype: ``bool`` """ resp = self.connection.request( "/v2/bare-metals/%s/reboot" % node.id, method="POST" ) return resp.success()
[docs] def ex_resize_node(self, node: Node, size: NodeSize) -> bool: """Change size for the given node, only applicable for VPS nodes. :param node: The node to be resized. :type node: :class: `Node` :param size: The new size. :type size: :class: `NodeSize` """ data = {"plan": size.id} resp = self.connection.request( "/v2/instances/%s" % node.id, data=json.dumps(data), method="PATCH" ) return self._to_node(resp.object["instance"])
[docs] def ex_start_bare_metal_node(self, node: Node) -> bool: """Start the given bare metal node. :param node: The bare metal node to be started. :type node: :class: `Node` :rtype: ``bool`` """ resp = self.connection.request( "/v2/bare-metals/%s/start" % node.id, method="POST" ) return resp.success()
[docs] def ex_stop_bare_metal_node(self, node: Node) -> bool: """Stop the given bare metal node. :param node: The bare metal node to be stopped. :type node: :class: `Node` :rtype: ``bool`` """ resp = self.connection.request( "/v2/bare-metals/%s/halt" % node.id, method="POST" ) return resp.success()
[docs] def ex_destroy_bare_metal_node(self, node: Node) -> bool: """Destroy the given bare metal node. :param node: The bare metal node to be destroyed. :type node: :class: `Node` :rtype: ``bool`` """ resp = self.connection.request("/v2/bare-metals/%s" % node.id, method="DELETE") return resp.success()
[docs] def ex_get_node(self, node_id: str) -> Node: """Retrieve a node object. :param node_id: ID of the node to retrieve. :type snapshot_id: ``str`` :rtype: :class: `Node` """ resp = self.connection.request("/v2/instances/%s" % node_id) return self._to_node(resp.object["instance"])
[docs] def ex_stop_nodes(self, nodes: List[Node]) -> bool: """Stops all the nodes given. : param nodes: A list of the nodes to stop. : type node: ``list`` of: class `Node` : rtype: ``bool`` """ data = {"instance_ids": [node.id for node in nodes]} resp = self.connection.request( "/v2/instances/halt", data=json.dumps(data), method="POST" ) return resp.success()
[docs] def ex_list_bare_metal_sizes(self) -> List[NodeSize]: """List bare metal sizes. :rtype: ``list`` of :class: `NodeSize` """ data = self._paginated_request("/v2/plans-metal", "plans_metal") return [self._to_size(item) for item in data]
[docs] def ex_list_snapshots(self) -> List[VultrNodeSnapshot]: """List node snapshots. :rtype: ``list`` of :class: `VultrNodeSnapshot` """ data = self._paginated_request("/v2/snapshots", "snapshots") return [self._to_snapshot(item) for item in data]
[docs] def ex_get_snapshot(self, snapshot_id: str) -> VultrNodeSnapshot: """Retrieve a snapshot. :param snapshot_id: ID of the snapshot to retrieve. :type snapshot_id: ``str`` :rtype: :class: `VultrNodeSnapshot` """ resp = self.connection.request("/v2/snapshots/%s" % snapshot_id) return self._to_snapshot(resp.object["snapshot"])
[docs] def ex_create_snapshot( self, node: Node, description: Optional[str] = None ) -> VultrNodeSnapshot: """Create snapshot from a node. :param node: Node to create the snapshot from. :type node: :class: `Node` :keyword description: A description of the snapshot. :type description: ``str`` :rtype: :class: `VultrNodeSnapshot` """ data = { "instance_id": node.id, } if description: data["description"] = description resp = self.connection.request( "/v2/snapshots", data=json.dumps(data), method="POST" ) return self._to_snapshot(resp.object["snapshot"])
[docs] def ex_delete_snapshot(self, snapshot: VultrNodeSnapshot) -> bool: """Delete the given snapshot. :param snapshot: The snapshot to delete. :type node: :class:`VultrNodeSnapshot` :rtype: ``bool`` """ resp = self.connection.request( "/v2/snapshots/%s" % snapshot.id, method="DELETE" ) return resp.success()
[docs] def ex_list_networks(self) -> List[VultrNetwork]: """List all private networks. :rtype: ``list`` of :class: `VultrNetwork` """ data = self._paginated_request("/v2/private-networks", "networks") return [self._to_network(item) for item in data]
[docs] def ex_create_network( self, cidr_block: str, location: Union[NodeLocation, str], description: Optional[str] = None, ) -> VultrNetwork: """Create a private network. :param cidr_block: The CIDR block assigned to the network. :type cidr_block: ``str`` :param location: The location to create the network. :type location: :class: `NodeLocation` or ``str`` :keyword description: A description of the private network. :type description: ``str`` :rtype: :class: `VultrNetwork` """ subnet, subnet_mask = cidr_block.split("/") data = { "v4_subnet": subnet, "v4_subnet_mask": int(subnet_mask), } try: data["region"] = location.id except AttributeError: data["region"] = location if description: data["description"] = description resp = self.connection.request( "/v2/private-networks", data=json.dumps(data), method="POST" ) return self._to_network(resp.object["network"])
[docs] def ex_get_network(self, network_id: str) -> VultrNetwork: """Retrieve a private network. :param network_id: ID of the network to retrieve. :type network_id: ``str`` :rtype: :class: `VultrNetwork` """ resp = self.connection.request("/v2/private-networks/%s" % network_id) return self._to_network(resp.object["network"])
[docs] def ex_destroy_network(self, network: VultrNetwork) -> bool: """Delete a private network. :param network: The network to destroy. :type network: :class: `VultrNetwork` :rtype: ``bool`` """ resp = self.connection.request( "/v2/private-networks/%s" % network.id, method="DELETE" ) return resp.success()
[docs] def ex_list_available_sizes_for_location( self, location: NodeLocation, ) -> List[str]: """Get a list of available sizes for the given location. :param location: The location to get available sizes for. :type location: :class: `NodeLocation` :return: A list of available size IDs for the given location. :rtype: ``list`` of ``str`` """ resp = self.connection.request("/v2/regions/%s/availability" % location.id) return resp.object["available_plans"]
[docs] def ex_get_volume(self, volume_id: str) -> StorageVolume: """Retrieve a single volume. :param volume_id: The ID of the volume to fetch. :type volume_id: ``str`` :rtype :class: `StorageVolume` :return: StorageVolume instance on success. """ resp = self.connection.request("/v2/blocks/%s" % volume_id) return self._to_volume(resp.object["block"])
[docs] def ex_resize_volume(self, volume: StorageVolume, size: int) -> bool: """Resize a volume. :param volume: The volume to resize. :type volume: :class:`StorageVolume` :param size: The new volume size in GBs.\ Size may range between 10 and 10000. :type size: ``int`` :rtype: ``bool`` """ data = { "label": volume.name, "size_gb": size, } resp = self.connection.request( "/v2/blocks/%s" % volume.id, data=json.dumps(data), method="PATCH" ) return resp.success()
def _is_bare_metal(self, size: Union[NodeSize, str]) -> bool: try: size_id = size.id except AttributeError: size_id = size return size_id.startswith("vbm") def _to_node(self, data: Dict[str, Any]) -> Node: id_ = data["id"] name = data["label"] public_ips = data["main_ip"].split() + data["v6_main_ip"].split() size = data["plan"] image = str(data["os_id"]) created_at = data["date_created"] is_bare_metal = self._is_bare_metal(size) extra = { "location": data["region"], "ram": data["ram"], "disk": data["disk"], "netmask_v4": data["netmask_v4"], "gateway_v4": data["gateway_v4"], "v6_network": data["v6_network"], "v6_network_size": data["v6_network_size"], "app_id": data["app_id"], "image_id": data["image_id"], "features": data["features"], "tag": data["tag"], "os": data["os"], "is_bare_metal": is_bare_metal, } if is_bare_metal: state = self._get_node_state(data["status"]) extra["cpu_count"] = data["cpu_count"] extra["mac_address"] = data["mac_address"] private_ips = None else: state = self._get_node_state( data["status"], power_state=data["power_status"] ) extra["vcpu_count"] = data["vcpu_count"] extra["allowed_bandwidth"] = data["allowed_bandwidth"] extra["power_status"] = data["power_status"] extra["server_status"] = data["server_status"] extra["firewall_group_id"] = data["firewall_group_id"] private_ips = data["internal_ip"].split() return Node( id=id_, name=name, state=state, public_ips=public_ips, private_ips=private_ips, driver=self, size=size, image=image, extra=extra, created_at=created_at, ) def _to_volume(self, data: Dict[str, Any]) -> StorageVolume: id_ = data["id"] name = data["label"] size = data["size_gb"] try: state = self.VOLUME_STATE_MAP[data["status"]] except KeyError: state = StorageVolumeState.UNKNOWN extra = { "date_created": data["date_created"], "cost": data["cost"], "location": data["region"], "attached_to_instance": data["attached_to_instance"], "mount_id": data["mount_id"], } return StorageVolume( id=id_, name=name, size=size, driver=self, state=state, extra=extra ) def _get_node_state( self, state: str, power_state: Optional[str] = None, ) -> NodeState: try: state = self.NODE_STATE_MAP[state] except KeyError: state = NodeState.UNKNOWN if power_state is None: return state if state == NodeState.RUNNING and power_state != "running": state = NodeState.STOPPED return state def _to_key_pair(self, data: Dict[str, Any]) -> KeyPair: name = data["name"] public_key = data["ssh_key"] # requires cryptography module try: fingerprint = get_pubkey_openssh_fingerprint(public_key) except RuntimeError: fingerprint = None extra = { "id": data["id"], "date_created": data["date_created"], } return KeyPair( name=name, public_key=public_key, fingerprint=fingerprint, driver=self, extra=extra, ) def _to_location(self, data: Dict[str, Any]) -> NodeLocation: id_ = data["id"] name = data["city"] country = data["country"] extra = { "continent": data["continent"], "option": data["options"], } return NodeLocation( id=id_, name=name, country=country, driver=self, extra=extra ) def _to_image(self, data: Dict[str, Any]) -> NodeImage: id_ = data["id"] name = data["name"] extra = { "arch": data["arch"], "family": data["family"], } return NodeImage(id=id_, name=name, driver=self, extra=extra) def _to_size(self, data: Dict[str, Any]) -> NodeSize: id_ = data["id"] ram = data["ram"] disk = data["disk"] bandwidth = data["bandwidth"] price = data["monthly_cost"] is_bare_metal = self._is_bare_metal(id_) extra = { "locations": data["locations"], "type": data["type"], "disk_count": data["disk_count"], "is_bare_metal": is_bare_metal, } # VPS and bare metal sizes have different fields if is_bare_metal is False: extra["vcpu_count"] = data["vcpu_count"] else: extra["cpu_count"] = data["cpu_count"] extra["cpu_model"] = data["cpu_model"] extra["cpu_threads"] = data["cpu_threads"] return NodeSize( id=id_, name=id_, ram=ram, disk=disk, bandwidth=bandwidth, price=price, driver=self, extra=extra, ) def _to_network(self, data: Dict[str, Any]) -> VultrNetwork: id_ = data["id"] cidr_block = "%s/%s" % (data["v4_subnet"], data["v4_subnet_mask"]) location = data["region"] extra = { "description": data["description"], "date_created": data["date_created"], } return VultrNetwork( id=id_, cidr_block=cidr_block, location=location, extra=extra ) def _to_snapshot(self, data: Dict[str, Any]) -> VultrNodeSnapshot: id_ = data["id"] created = data["date_created"] # Size is returned in bytes, convert to GBs size = data["size"] / 1024 / 1024 / 1024 try: state = self.SNAPSHOT_STATE_MAP[data["status"]] except KeyError: state = VolumeSnapshotState.UNKNOWN extra = { "description": data["description"], "os_id": data["os_id"], "app_id": data["app_id"], } return VultrNodeSnapshot( id=id_, size=size, created=created, state=state, extra=extra, driver=self ) def _paginated_request( self, url: str, key: str, params: Optional[Dict[str, Any]] = None ) -> List[Any]: """Perform multiple calls to get the full list of items when the API responses are paginated. :param url: API endpoint :type url: ``str`` :param key: Result object key :type key: ``str`` :param params: Request parameters :type params: ``dict`` :return: ``list`` of API response objects :rtype: ``list`` """ params = params if params is not None else {} resp = self.connection.request(url, params=params).object data = list(resp.get(key, [])) objects = data while True: next_page = resp["meta"]["links"]["next"] if next_page: params["cursor"] = next_page resp = self.connection.request(url, params=params).object data = list(resp.get(key, [])) objects.extend(data) else: return objects