diff --git a/libcloud/common/upcloud.py b/libcloud/common/upcloud.py index 5affb939a0..5b4273da8e 100644 --- a/libcloud/common/upcloud.py +++ b/libcloud/common/upcloud.py @@ -52,6 +52,10 @@ class UpcloudCreateNodeRequestBody: :param ex_username: User's username, which is created. Default is 'root'. (optional) :type ex_username: ``str`` + + :param ex_storage_devices: Additional UpCloud storage_device dictionaries. + (optional) + :type ex_storage_devices: ``list`` of ``dict`` """ def __init__( @@ -63,7 +67,12 @@ def __init__( auth=None, ex_hostname="localhost", ex_username="root", + ex_storage_devices=None, ): + storage_devices = _StorageDevice(image, size).to_dict() + if ex_storage_devices: + storage_devices["storage_device"].extend(ex_storage_devices) + self.body = { "server": { "title": name, @@ -71,7 +80,7 @@ def __init__( "plan": size.id, "zone": location.id, "login_user": _LoginUser(ex_username, auth).to_dict(), - "storage_devices": _StorageDevice(image, size).to_dict(), + "storage_devices": storage_devices, } } @@ -172,6 +181,15 @@ def stop_node(self, node_id): "1.2/server/{}/stop".format(node_id), method="POST", data=json.dumps(body) ) + def start_node(self, node_id): + """ + Starts the node + + :param node_id: Id of the Node + :type node_id: ``int`` + """ + self.connection.request("1.2/server/{}/start".format(node_id), method="POST") + def get_node_state(self, node_id): """ Get the state of the node. diff --git a/libcloud/compute/drivers/upcloud.py b/libcloud/compute/drivers/upcloud.py index 700c465012..3bdd79cd32 100644 --- a/libcloud/compute/drivers/upcloud.py +++ b/libcloud/compute/drivers/upcloud.py @@ -22,8 +22,16 @@ 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, NodeState, NodeDriver, NodeLocation -from libcloud.compute.types import Provider +from libcloud.compute.base import ( + Node, + NodeSize, + NodeImage, + NodeState, + NodeDriver, + NodeLocation, + StorageVolume, +) +from libcloud.compute.types import Provider, StorageVolumeState from libcloud.common.upcloud import ( PlanPrice, UpcloudNodeDestroyer, @@ -95,6 +103,15 @@ class UpcloudDriver(NodeDriver): "error": NodeState.ERROR, } + STORAGE_VOLUME_STATE_MAP = { + "online": StorageVolumeState.AVAILABLE, + "maintenance": StorageVolumeState.UPDATING, + "cloning": StorageVolumeState.CREATING, + "backuping": StorageVolumeState.BACKUP, + "syncing": StorageVolumeState.MIGRATING, + "error": StorageVolumeState.ERROR, + } + def __init__(self, username, password, **kwargs): super().__init__(key=username, secret=password, **kwargs) @@ -148,6 +165,7 @@ def create_node( auth=None, ex_hostname="localhost", ex_username="root", + ex_storage_devices=None, ): """ Creates instance to upcloud. @@ -179,6 +197,14 @@ def create_node( Default is 'root'. (optional) :type ex_username: ``str`` + :param ex_storage_devices: Additional UpCloud storage_device + dictionaries to include in the server + creation request. For example, an + ``attach`` action can attach an existing + storage and a ``create`` action can create + an extra data disk. (optional) + :type ex_storage_devices: ``list`` of ``dict`` + :return: The newly created node. :rtype: :class:`.Node` """ @@ -190,6 +216,7 @@ def create_node( auth=auth, ex_hostname=ex_hostname, ex_username=ex_username, + ex_storage_devices=ex_storage_devices, ) response = self.connection.request("1.2/server", method="POST", data=body.to_json()) server = response.object["server"] @@ -197,6 +224,156 @@ def create_node( # from state to other, it is safe to assume STARTING state return self._to_node(server, state=NodeState.STARTING) + def list_volumes(self): + """ + List normal storage volumes. + + :rtype: ``list`` of :class:`StorageVolume` + """ + response = self.connection.request("1.2/storage/normal") + return self._to_volumes(response.object["storages"]["storage"]) + + def create_volume( + self, + size, + name, + location=None, + snapshot=None, + ex_tier="maxiops", + ex_backup_rule=None, + ): + """ + Create a new storage volume. + + :param size: Size of volume in gigabytes. (required) + :type size: ``int`` + + :param name: Name of the volume to be created. (required) + :type name: ``str`` + + :param location: Which data center to create a volume in. (required) + :type location: :class:`.NodeLocation` + + :param ex_tier: UpCloud storage tier: ``maxiops`` or ``hdd``. + Default is ``maxiops``. (optional) + :type ex_tier: ``str`` + + :param ex_backup_rule: Backup rule block for automatic backups. + (optional) + :type ex_backup_rule: ``dict`` + + :rtype: :class:`StorageVolume` + """ + if location is None: + raise ValueError("Must provide `location` value.") + + if snapshot is not None: + raise NotImplementedError("Creating a volume from snapshot is not supported.") + + storage = { + "size": size, + "title": name, + "zone": location.id, + "tier": ex_tier, + } + if ex_backup_rule is not None: + storage["backup_rule"] = ex_backup_rule + + response = self.connection.request( + "1.2/storage", + method="POST", + data=json.dumps({"storage": storage}), + ) + return self._to_volume(response.object["storage"]) + + def attach_volume( + self, + node, + volume, + device=None, + ex_type="disk", + ex_boot_disk=False, + ): + """ + Attach a storage volume to a node. + + :param node: Node to attach volume to. + :type node: :class:`Node` + + :param volume: Volume to attach. + :type volume: :class:`StorageVolume` + + :param device: UpCloud device address or bus, for example + ``virtio``, ``scsi`` or ``scsi:0:0``. (optional) + :type device: ``str`` + + :param ex_type: Attached device type, ``disk`` or ``cdrom``. + Default is ``disk``. (optional) + :type ex_type: ``str`` + + :param ex_boot_disk: Whether the storage should be a boot disk. + Default is False. (optional) + :type ex_boot_disk: ``bool`` + + :rtype: ``bool`` + """ + storage_device = { + "type": ex_type, + "storage": volume.id, + "boot_disk": "1" if ex_boot_disk else "0", + } + if device is not None: + storage_device["address"] = device + + self.connection.request( + "1.2/server/{}/storage/attach".format(node.id), + method="POST", + data=json.dumps({"storage_device": storage_device}), + ) + return True + + def detach_volume(self, volume, ex_node=None, ex_address=None): + """ + Detach a storage volume from its server. + + :param volume: Volume to detach. + :type volume: :class:`StorageVolume` + + :param ex_node: Node where the volume is attached. Required by the + UpCloud 1.2 detach endpoint. + :type ex_node: :class:`Node` + + :param ex_address: Device address to detach, for example + ``scsi:0:0``. Required by the UpCloud 1.2 detach + endpoint. + :type ex_address: ``str`` + + :rtype: ``bool`` + """ + if ex_node is None or ex_address is None: + raise ValueError( + "UpCloud API 1.2 requires `ex_node` and `ex_address` " "when detaching a volume." + ) + + self.connection.request( + "1.2/server/{}/storage/detach".format(ex_node.id), + method="POST", + data=json.dumps({"storage_device": {"address": ex_address}}), + ) + return True + + def destroy_volume(self, volume): + """ + Destroy a storage volume. + + :param volume: Volume to destroy. + :type volume: :class:`StorageVolume` + + :rtype: ``bool`` + """ + self.connection.request("1.2/storage/{}".format(volume.id), method="DELETE") + return True + def list_nodes(self): """ List nodes @@ -227,6 +404,47 @@ def reboot_node(self, node): ) return True + def start_node(self, node): + """ + Start the given node. + + :param node: the node to start + :type node: :class:`Node` + + :rtype: ``bool`` + """ + self.connection.request("1.2/server/{}/start".format(node.id), method="POST") + return True + + def stop_node(self, node, ex_stop_type="hard", ex_timeout=None): + """ + Stop the given node. + + :param node: the node to stop + :type node: :class:`Node` + + :param ex_stop_type: Stop type, ``hard`` or ``soft``. Default is + ``hard`` to match the destroy helper behavior. + (optional) + :type ex_stop_type: ``str`` + + :param ex_timeout: Stop timeout in seconds when using a soft stop. + (optional) + :type ex_timeout: ``int`` + + :rtype: ``bool`` + """ + stop_server = {"stop_type": ex_stop_type} + if ex_timeout is not None: + stop_server["timeout"] = ex_timeout + + self.connection.request( + "1.2/server/{}/stop".format(node.id), + method="POST", + data=json.dumps({"stop_server": stop_server}), + ) + return True + def destroy_node(self, node): """ Destroy the given node @@ -312,6 +530,39 @@ def _construct_node_image(self, image): extra = self._copy_dict(("access", "license", "size", "state", "type"), image) return NodeImage(id=image["uuid"], name=image["title"], driver=self, extra=extra) + def _to_volumes(self, volumes): + return [self._to_volume(volume) for volume in volumes] + + def _to_volume(self, volume): + extra_keys = ( + "access", + "backup_rule", + "backups", + "encrypted", + "labels", + "license", + "origin", + "part_of_plan", + "progress", + "servers", + "tier", + "type", + "zone", + ) + extra = {} + for key in extra_keys: + if key in volume: + extra[key] = volume[key] + + return StorageVolume( + id=volume["uuid"], + name=volume["title"], + size=int(volume["size"]), + driver=self, + state=self.STORAGE_VOLUME_STATE_MAP.get(volume["state"], StorageVolumeState.UNKNOWN), + extra=extra, + ) + def _copy_dict(self, keys, d): extra = {} for key in keys: diff --git a/libcloud/test/compute/fixtures/upcloud/api_1_2_storage_01d4fcd4-e446-433b-8a9c-551a1284952e.json b/libcloud/test/compute/fixtures/upcloud/api_1_2_storage_01d4fcd4-e446-433b-8a9c-551a1284952e.json new file mode 100644 index 0000000000..10f3e77387 --- /dev/null +++ b/libcloud/test/compute/fixtures/upcloud/api_1_2_storage_01d4fcd4-e446-433b-8a9c-551a1284952e.json @@ -0,0 +1,22 @@ +{ + "storage": { + "access": "private", + "backup_rule": "", + "backups": { + "backup": [] + }, + "license": 0, + "servers": { + "server": [ + "00f8c525-7e62-4108-8115-3958df5b43dc" + ] + }, + "size": 50, + "state": "online", + "tier": "maxiops", + "title": "data", + "type": "normal", + "uuid": "01d4fcd4-e446-433b-8a9c-551a1284952e", + "zone": "fi-hel1" + } +} diff --git a/libcloud/test/compute/fixtures/upcloud/api_1_2_storage_create.json b/libcloud/test/compute/fixtures/upcloud/api_1_2_storage_create.json new file mode 100644 index 0000000000..60dc270948 --- /dev/null +++ b/libcloud/test/compute/fixtures/upcloud/api_1_2_storage_create.json @@ -0,0 +1,20 @@ +{ + "storage": { + "access": "private", + "backup_rule": "", + "backups": { + "backup": [] + }, + "license": 0, + "servers": { + "server": [] + }, + "size": 50, + "state": "online", + "tier": "maxiops", + "title": "data", + "type": "normal", + "uuid": "01d4fcd4-e446-433b-8a9c-551a1284952e", + "zone": "fi-hel1" + } +} diff --git a/libcloud/test/compute/fixtures/upcloud/api_1_2_storage_normal.json b/libcloud/test/compute/fixtures/upcloud/api_1_2_storage_normal.json new file mode 100644 index 0000000000..1fdc1cbef5 --- /dev/null +++ b/libcloud/test/compute/fixtures/upcloud/api_1_2_storage_normal.json @@ -0,0 +1,31 @@ +{ + "storages": { + "storage": [ + { + "access": "private", + "license": 0, + "size": 10, + "state": "online", + "tier": "hdd", + "title": "Operating system disk", + "type": "normal", + "uuid": "01eff7ad-168e-413e-83b0-054f6a28fa23", + "zone": "uk-lon1" + }, + { + "access": "private", + "license": 0, + "servers": { + "server": [] + }, + "size": 50, + "state": "online", + "tier": "maxiops", + "title": "data", + "type": "normal", + "uuid": "01d4fcd4-e446-433b-8a9c-551a1284952e", + "zone": "fi-hel1" + } + ] + } +} diff --git a/libcloud/test/compute/test_upcloud.py b/libcloud/test/compute/test_upcloud.py index d6f86890bd..b53d4866c3 100644 --- a/libcloud/test/compute/test_upcloud.py +++ b/libcloud/test/compute/test_upcloud.py @@ -23,7 +23,14 @@ from libcloud.compute import providers from libcloud.utils.py3 import httplib, ensure_string from libcloud.common.types import InvalidCredsError -from libcloud.compute.base import Node, NodeSize, NodeImage, NodeLocation, NodeAuthSSHKey +from libcloud.compute.base import ( + Node, + NodeSize, + NodeImage, + NodeLocation, + StorageVolume, + NodeAuthSSHKey, +) from libcloud.test.secrets import UPCLOUD_PARAMS from libcloud.compute.types import Provider, NodeState from libcloud.test.file_fixtures import ComputeFileFixtures @@ -192,6 +199,107 @@ def test_create_node_with_ssh_keys(self): self.assertTrue(len(node.private_ips) > 0) self.assertEqual(node.driver, self.driver) + def test_create_node_with_extra_storage_devices(self): + image = NodeImage( + id="01000000-0000-4000-8000-000030060200", + name="Ubuntu Server 16.04 LTS (Xenial Xerus)", + extra={"type": "template"}, + driver=self.driver, + ) + location = NodeLocation(id="fi-hel1", name="Helsinki #1", country="FI", driver=self.driver) + size = NodeSize( + id="1xCPU-1GB", + name="1xCPU-1GB", + ram=1024, + disk=30, + bandwidth=2048, + extra={"storage_tier": "maxiops"}, + price=None, + driver=self.driver, + ) + extra_storage = { + "action": "create", + "title": "data", + "size": 25, + "tier": "maxiops", + } + + self.driver.create_node( + name="test_server", + size=size, + image=image, + location=location, + ex_storage_devices=[extra_storage], + ) + + storage_devices = UpcloudMockHttp.last_request_body["server"]["storage_devices"][ + "storage_device" + ] + self.assertEqual(len(storage_devices), 2) + self.assertEqual(storage_devices[1], extra_storage) + + def test_list_volumes(self): + volumes = self.driver.list_volumes() + self.assertEqual(len(volumes), 2) + + volume = volumes[0] + self.assertIsInstance(volume, StorageVolume) + self.assertEqual(volume.id, "01eff7ad-168e-413e-83b0-054f6a28fa23") + self.assertEqual(volume.name, "Operating system disk") + self.assertEqual(volume.size, 10) + self.assertEqual(volume.extra["tier"], "hdd") + self.assertEqual(volume.extra["zone"], "uk-lon1") + + def test_create_volume(self): + location = NodeLocation(id="fi-hel1", name="Helsinki #1", country="FI", driver=self.driver) + volume = self.driver.create_volume( + size=50, + name="data", + location=location, + ex_tier="maxiops", + ) + + self.assertEqual(volume.id, "01d4fcd4-e446-433b-8a9c-551a1284952e") + self.assertEqual(volume.name, "data") + self.assertEqual(volume.size, 50) + request_storage = UpcloudMockHttp.last_request_body["storage"] + self.assertEqual(request_storage["tier"], "maxiops") + self.assertNotIn("encrypted", request_storage) + self.assertNotIn("labels", request_storage) + + def test_create_volume_requires_location(self): + with self.assertRaises(ValueError): + self.driver.create_volume(size=50, name="data") + + def test_attach_volume(self): + node = self.driver.list_nodes()[0] + volume = self.driver.list_volumes()[1] + + success = self.driver.attach_volume(node, volume, device="scsi") + + self.assertTrue(success) + request_device = UpcloudMockHttp.last_request_body["storage_device"] + self.assertEqual(request_device["storage"], volume.id) + self.assertEqual(request_device["address"], "scsi") + self.assertEqual(request_device["boot_disk"], "0") + + def test_detach_volume(self): + node = self.driver.list_nodes()[0] + volume = self.driver.list_volumes()[1] + self.assertTrue(self.driver.detach_volume(volume, ex_node=node, ex_address="scsi:0:0")) + + request_device = UpcloudMockHttp.last_request_body["storage_device"] + self.assertEqual(request_device["address"], "scsi:0:0") + + def test_detach_volume_requires_node_and_address(self): + volume = self.driver.list_volumes()[1] + with self.assertRaises(ValueError): + self.driver.detach_volume(volume) + + def test_destroy_volume(self): + volume = self.driver.list_volumes()[1] + self.assertTrue(self.driver.destroy_volume(volume)) + def test_list_nodes(self): nodes = self.driver.list_nodes() @@ -208,6 +316,23 @@ def test_reboot_node(self): success = self.driver.reboot_node(nodes[0]) self.assertTrue(success) + def test_start_node(self): + nodes = self.driver.list_nodes() + success = self.driver.start_node(nodes[0]) + + self.assertTrue(success) + self.assertIsNone(UpcloudMockHttp.last_request_body) + + def test_stop_node(self): + nodes = self.driver.list_nodes() + success = self.driver.stop_node(nodes[0], ex_stop_type="soft", ex_timeout=60) + + self.assertTrue(success) + self.assertEqual( + UpcloudMockHttp.last_request_body, + {"stop_server": {"stop_type": "soft", "timeout": 60}}, + ) + def test_destroy_node(self): if UpcloudDriver.connectionCls.conn_class == UpcloudMockHttp: nodes = [ @@ -258,6 +383,7 @@ def dicts_equals(self, d1, d2): class UpcloudMockHttp(MockHttp): fixtures = ComputeFileFixtures("upcloud") + last_request_body = None def _1_2_zone(self, method, url, body, headers): auth = headers["Authorization"].split(" ")[1] @@ -289,6 +415,7 @@ def _1_2_price(self, method, url, body, headers): def _1_2_server(self, method, url, body, headers): if method == "POST": dbody = json.loads(body) + self.__class__.last_request_body = dbody storages = dbody["server"]["storage_devices"]["storage_device"] if any(["type" in storage and storage["type"] == "cdrom" for storage in storages]): body = self.fixtures.load("api_1_2_server_from_cdrom.json") @@ -298,6 +425,32 @@ def _1_2_server(self, method, url, body, headers): body = self.fixtures.load("api_1_2_server.json") return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + def _1_2_storage_normal(self, method, url, body, headers): + body = self.fixtures.load("api_1_2_storage_normal.json") + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _1_2_storage(self, method, url, body, headers): + self.__class__.last_request_body = json.loads(body) + body = self.fixtures.load("api_1_2_storage_create.json") + return (httplib.CREATED, body, {}, httplib.responses[httplib.CREATED]) + + def _1_2_server_00f8c525_7e62_4108_8115_3958df5b43dc_storage_attach( + self, method, url, body, headers + ): + self.__class__.last_request_body = json.loads(body) + body = self.fixtures.load("api_1_2_server_00f8c525-7e62-4108-8115-3958df5b43dc.json") + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _1_2_server_00f8c525_7e62_4108_8115_3958df5b43dc_storage_detach( + self, method, url, body, headers + ): + self.__class__.last_request_body = json.loads(body) + body = self.fixtures.load("api_1_2_server_00f8c525-7e62-4108-8115-3958df5b43dc.json") + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _1_2_storage_01d4fcd4_e446_433b_8a9c_551a1284952e(self, method, url, body, headers): + return (httplib.NO_CONTENT, "", {}, httplib.responses[httplib.NO_CONTENT]) + def _1_2_server_00f8c525_7e62_4108_8115_3958df5b43dc(self, method, url, body, headers): body = self.fixtures.load("api_1_2_server_00f8c525-7e62-4108-8115-3958df5b43dc.json") return (httplib.OK, body, {}, httplib.responses[httplib.OK]) @@ -308,6 +461,16 @@ def _1_2_server_00f8c525_7e62_4108_8115_3958df5b43dc_restart(self, method, url, ) return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + def _1_2_server_00f8c525_7e62_4108_8115_3958df5b43dc_start(self, method, url, body, headers): + self.__class__.last_request_body = body + body = self.fixtures.load("api_1_2_server_00f8c525-7e62-4108-8115-3958df5b43dc.json") + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _1_2_server_00f8c525_7e62_4108_8115_3958df5b43dc_stop(self, method, url, body, headers): + self.__class__.last_request_body = json.loads(body) + body = self.fixtures.load("api_1_2_server_00f8c525-7e62-4108-8115-3958df5b43dc.json") + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + def _1_2_server_00893c98_5d5a_4363_b177_88df518a2b60(self, method, url, body, headers): body = self.fixtures.load("api_1_2_server_00893c98-5d5a-4363-b177-88df518a2b60.json") return (httplib.OK, body, {}, httplib.responses[httplib.OK])