From cf74985387d859265ac8256a402679f7ac793846 Mon Sep 17 00:00:00 2001 From: Miguel Caballer Date: Fri, 3 Jul 2026 12:06:31 +0200 Subject: [PATCH 1/7] Add volume support and start stop operations --- libcloud/common/upcloud.py | 24 +- libcloud/compute/drivers/upcloud.py | 283 +++++++++++++++++- ..._01d4fcd4-e446-433b-8a9c-551a1284952e.json | 29 ++ .../upcloud/api_1_2_storage_create.json | 27 ++ .../upcloud/api_1_2_storage_normal.json | 40 +++ libcloud/test/compute/test_upcloud.py | 173 ++++++++++- 6 files changed, 572 insertions(+), 4 deletions(-) create mode 100644 libcloud/test/compute/fixtures/upcloud/api_1_2_storage_01d4fcd4-e446-433b-8a9c-551a1284952e.json create mode 100644 libcloud/test/compute/fixtures/upcloud/api_1_2_storage_create.json create mode 100644 libcloud/test/compute/fixtures/upcloud/api_1_2_storage_normal.json diff --git a/libcloud/common/upcloud.py b/libcloud/common/upcloud.py index 5affb939a0..632695335c 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,19 @@ 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", + data=json.dumps({"server": {}}), + ) + 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..1f781d19ab 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,153 @@ 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_encrypted=False, + ex_labels=None, + 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``, ``standard``, or + ``hdd``. Default is ``maxiops``. (optional) + :type ex_tier: ``str`` + + :param ex_encrypted: Create the volume encrypted at rest. Default is + False. (optional) + :type ex_encrypted: ``bool`` + + :param ex_labels: Labels for the volume. (optional) + :type ex_labels: ``list`` of ``dict`` + + :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, + "encrypted": "yes" if ex_encrypted else "no", + } + if ex_labels is not None: + storage["labels"] = ex_labels + 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: :class:`StorageVolume` + """ + storage_device = { + "type": ex_type, + "server": node.id, + "boot_disk": "1" if ex_boot_disk else "0", + } + if device is not None: + storage_device["address"] = device + + response = self.connection.request( + "1.2/storage/{}/attach".format(volume.id), + method="POST", + data=json.dumps({"storage_device": storage_device}), + ) + return self._to_volume(response.object["storage"]) + + def detach_volume(self, volume): + """ + Detach a storage volume from its server. + + :param volume: Volume to detach. + :type volume: :class:`StorageVolume` + + :rtype: ``bool`` + """ + self.connection.request( + "1.2/storage/{}/detach".format(volume.id), + method="POST", + ) + 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 +401,76 @@ def reboot_node(self, node): ) return True + def start_node( + self, + node, + ex_host=None, + ex_avoid_host=None, + ex_start_type=None, + ): + """ + Start the given node. + + :param node: the node to start + :type node: :class:`Node` + + :param ex_host: Host id to start the node on. Only available for + private cloud hosts. (optional) + :type ex_host: ``int`` + + :param ex_avoid_host: Host id to avoid when starting the node. + (optional) + :type ex_avoid_host: ``int`` + + :param ex_start_type: Start type, ``sync`` or ``async``. (optional) + :type ex_start_type: ``str`` + + :rtype: ``bool`` + """ + server = {} + if ex_host is not None: + server["host"] = ex_host + if ex_avoid_host is not None: + server["avoid_host"] = ex_avoid_host + if ex_start_type is not None: + server["start_type"] = ex_start_type + + self.connection.request( + "1.2/server/{}/start".format(node.id), + method="POST", + data=json.dumps({"server": server}), + ) + 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 +556,41 @@ 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..2269ac4158 --- /dev/null +++ b/libcloud/test/compute/fixtures/upcloud/api_1_2_storage_01d4fcd4-e446-433b-8a9c-551a1284952e.json @@ -0,0 +1,29 @@ +{ + "storage": { + "access": "private", + "encrypted": "yes", + "backup_rule": "", + "backups": { + "backup": [] + }, + "labels": [ + { + "key": "env", + "value": "test" + } + ], + "license": 0, + "servers": { + "server": [ + "00f8c525-7e62-4108-8115-3958df5b43dc" + ] + }, + "size": 50, + "state": "online", + "tier": "standard", + "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..aa16983f18 --- /dev/null +++ b/libcloud/test/compute/fixtures/upcloud/api_1_2_storage_create.json @@ -0,0 +1,27 @@ +{ + "storage": { + "access": "private", + "encrypted": "yes", + "backup_rule": "", + "backups": { + "backup": [] + }, + "labels": [ + { + "key": "env", + "value": "test" + } + ], + "license": 0, + "servers": { + "server": [] + }, + "size": 50, + "state": "online", + "tier": "standard", + "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..2c274c5d2a --- /dev/null +++ b/libcloud/test/compute/fixtures/upcloud/api_1_2_storage_normal.json @@ -0,0 +1,40 @@ +{ + "storages": { + "storage": [ + { + "access": "private", + "encrypted": "no", + "labels": [], + "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", + "encrypted": "yes", + "labels": [ + { + "key": "env", + "value": "test" + } + ], + "license": 0, + "servers": { + "server": [] + }, + "size": 50, + "state": "online", + "tier": "standard", + "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..06d5a7c6bd 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, + NodeAuthSSHKey, + StorageVolume, +) from libcloud.test.secrets import UPCLOUD_PARAMS from libcloud.compute.types import Provider, NodeState from libcloud.test.file_fixtures import ComputeFileFixtures @@ -192,6 +199,102 @@ 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": "standard", + } + + 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="standard", + ex_encrypted=True, + ex_labels=[{"key": "env", "value": "test"}], + ) + + self.assertEqual(volume.id, "01d4fcd4-e446-433b-8a9c-551a1284952e") + self.assertEqual(volume.name, "data") + self.assertEqual(volume.size, 50) + self.assertEqual(volume.extra["encrypted"], "yes") + request_storage = UpcloudMockHttp.last_request_body["storage"] + self.assertEqual(request_storage["tier"], "standard") + self.assertEqual(request_storage["encrypted"], "yes") + self.assertEqual(request_storage["labels"], [{"key": "env", "value": "test"}]) + + 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] + + attached_volume = self.driver.attach_volume(node, volume, device="scsi") + + self.assertIsInstance(attached_volume, StorageVolume) + self.assertEqual(attached_volume.id, volume.id) + request_device = UpcloudMockHttp.last_request_body["storage_device"] + self.assertEqual(request_device["server"], node.id) + self.assertEqual(request_device["address"], "scsi") + self.assertEqual(request_device["boot_disk"], "0") + + def test_detach_volume(self): + volume = self.driver.list_volumes()[1] + self.assertTrue(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 +311,37 @@ 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], + ex_host=8055964291, + ex_avoid_host=7653311107, + ex_start_type="async", + ) + + self.assertTrue(success) + self.assertEqual( + UpcloudMockHttp.last_request_body, + { + "server": { + "host": 8055964291, + "avoid_host": 7653311107, + "start_type": "async", + } + }, + ) + + 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 +392,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 +424,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 +434,31 @@ 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_storage_01d4fcd4_e446_433b_8a9c_551a1284952e_attach( + self, method, url, body, headers + ): + self.__class__.last_request_body = json.loads(body) + body = self.fixtures.load("api_1_2_storage_01d4fcd4-e446-433b-8a9c-551a1284952e.json") + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + + def _1_2_storage_01d4fcd4_e446_433b_8a9c_551a1284952e_detach( + self, method, url, body, headers + ): + body = self.fixtures.load("api_1_2_storage_01d4fcd4-e446-433b-8a9c-551a1284952e.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 +469,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 = 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_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]) From 4a9e7d09f5a747acc5306c056febef4ccc70ae59 Mon Sep 17 00:00:00 2001 From: Miguel Caballer Date: Fri, 3 Jul 2026 12:14:17 +0200 Subject: [PATCH 2/7] Fix to 1.2 version --- libcloud/common/upcloud.py | 6 +- libcloud/compute/drivers/upcloud.py | 79 +++++++------------ ..._01d4fcd4-e446-433b-8a9c-551a1284952e.json | 9 +-- .../upcloud/api_1_2_storage_create.json | 9 +-- .../upcloud/api_1_2_storage_normal.json | 11 +-- libcloud/test/compute/test_upcloud.py | 60 ++++++-------- 6 files changed, 57 insertions(+), 117 deletions(-) diff --git a/libcloud/common/upcloud.py b/libcloud/common/upcloud.py index 632695335c..5b4273da8e 100644 --- a/libcloud/common/upcloud.py +++ b/libcloud/common/upcloud.py @@ -188,11 +188,7 @@ def start_node(self, node_id): :param node_id: Id of the Node :type node_id: ``int`` """ - self.connection.request( - "1.2/server/{}/start".format(node_id), - method="POST", - data=json.dumps({"server": {}}), - ) + self.connection.request("1.2/server/{}/start".format(node_id), method="POST") def get_node_state(self, node_id): """ diff --git a/libcloud/compute/drivers/upcloud.py b/libcloud/compute/drivers/upcloud.py index 1f781d19ab..36dbf4eddd 100644 --- a/libcloud/compute/drivers/upcloud.py +++ b/libcloud/compute/drivers/upcloud.py @@ -240,8 +240,6 @@ def create_volume( location=None, snapshot=None, ex_tier="maxiops", - ex_encrypted=False, - ex_labels=None, ex_backup_rule=None, ): """ @@ -256,17 +254,10 @@ def create_volume( :param location: Which data center to create a volume in. (required) :type location: :class:`.NodeLocation` - :param ex_tier: UpCloud storage tier: ``maxiops``, ``standard``, or - ``hdd``. Default is ``maxiops``. (optional) + :param ex_tier: UpCloud storage tier: ``maxiops`` or ``hdd``. + Default is ``maxiops``. (optional) :type ex_tier: ``str`` - :param ex_encrypted: Create the volume encrypted at rest. Default is - False. (optional) - :type ex_encrypted: ``bool`` - - :param ex_labels: Labels for the volume. (optional) - :type ex_labels: ``list`` of ``dict`` - :param ex_backup_rule: Backup rule block for automatic backups. (optional) :type ex_backup_rule: ``dict`` @@ -284,10 +275,7 @@ def create_volume( "title": name, "zone": location.id, "tier": ex_tier, - "encrypted": "yes" if ex_encrypted else "no", } - if ex_labels is not None: - storage["labels"] = ex_labels if ex_backup_rule is not None: storage["backup_rule"] = ex_backup_rule @@ -327,35 +315,51 @@ def attach_volume( Default is False. (optional) :type ex_boot_disk: ``bool`` - :rtype: :class:`StorageVolume` + :rtype: ``bool`` """ storage_device = { "type": ex_type, - "server": node.id, + "storage": volume.id, "boot_disk": "1" if ex_boot_disk else "0", } if device is not None: storage_device["address"] = device - response = self.connection.request( - "1.2/storage/{}/attach".format(volume.id), + self.connection.request( + "1.2/server/{}/storage/attach".format(node.id), method="POST", data=json.dumps({"storage_device": storage_device}), ) - return self._to_volume(response.object["storage"]) + return True - def detach_volume(self, volume): + 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/storage/{}/detach".format(volume.id), + "1.2/server/{}/storage/detach".format(ex_node.id), method="POST", + data=json.dumps({"storage_device": {"address": ex_address}}), ) return True @@ -401,45 +405,16 @@ def reboot_node(self, node): ) return True - def start_node( - self, - node, - ex_host=None, - ex_avoid_host=None, - ex_start_type=None, - ): + def start_node(self, node): """ Start the given node. :param node: the node to start :type node: :class:`Node` - :param ex_host: Host id to start the node on. Only available for - private cloud hosts. (optional) - :type ex_host: ``int`` - - :param ex_avoid_host: Host id to avoid when starting the node. - (optional) - :type ex_avoid_host: ``int`` - - :param ex_start_type: Start type, ``sync`` or ``async``. (optional) - :type ex_start_type: ``str`` - :rtype: ``bool`` """ - server = {} - if ex_host is not None: - server["host"] = ex_host - if ex_avoid_host is not None: - server["avoid_host"] = ex_avoid_host - if ex_start_type is not None: - server["start_type"] = ex_start_type - - self.connection.request( - "1.2/server/{}/start".format(node.id), - method="POST", - data=json.dumps({"server": server}), - ) + 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): 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 index 2269ac4158..10f3e77387 100644 --- 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 @@ -1,17 +1,10 @@ { "storage": { "access": "private", - "encrypted": "yes", "backup_rule": "", "backups": { "backup": [] }, - "labels": [ - { - "key": "env", - "value": "test" - } - ], "license": 0, "servers": { "server": [ @@ -20,7 +13,7 @@ }, "size": 50, "state": "online", - "tier": "standard", + "tier": "maxiops", "title": "data", "type": "normal", "uuid": "01d4fcd4-e446-433b-8a9c-551a1284952e", 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 index aa16983f18..60dc270948 100644 --- a/libcloud/test/compute/fixtures/upcloud/api_1_2_storage_create.json +++ b/libcloud/test/compute/fixtures/upcloud/api_1_2_storage_create.json @@ -1,24 +1,17 @@ { "storage": { "access": "private", - "encrypted": "yes", "backup_rule": "", "backups": { "backup": [] }, - "labels": [ - { - "key": "env", - "value": "test" - } - ], "license": 0, "servers": { "server": [] }, "size": 50, "state": "online", - "tier": "standard", + "tier": "maxiops", "title": "data", "type": "normal", "uuid": "01d4fcd4-e446-433b-8a9c-551a1284952e", 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 index 2c274c5d2a..1fdc1cbef5 100644 --- a/libcloud/test/compute/fixtures/upcloud/api_1_2_storage_normal.json +++ b/libcloud/test/compute/fixtures/upcloud/api_1_2_storage_normal.json @@ -3,8 +3,6 @@ "storage": [ { "access": "private", - "encrypted": "no", - "labels": [], "license": 0, "size": 10, "state": "online", @@ -16,20 +14,13 @@ }, { "access": "private", - "encrypted": "yes", - "labels": [ - { - "key": "env", - "value": "test" - } - ], "license": 0, "servers": { "server": [] }, "size": 50, "state": "online", - "tier": "standard", + "tier": "maxiops", "title": "data", "type": "normal", "uuid": "01d4fcd4-e446-433b-8a9c-551a1284952e", diff --git a/libcloud/test/compute/test_upcloud.py b/libcloud/test/compute/test_upcloud.py index 06d5a7c6bd..a8ea45cf6c 100644 --- a/libcloud/test/compute/test_upcloud.py +++ b/libcloud/test/compute/test_upcloud.py @@ -221,7 +221,7 @@ def test_create_node_with_extra_storage_devices(self): "action": "create", "title": "data", "size": 25, - "tier": "standard", + "tier": "maxiops", } self.driver.create_node( @@ -256,19 +256,16 @@ def test_create_volume(self): size=50, name="data", location=location, - ex_tier="standard", - ex_encrypted=True, - ex_labels=[{"key": "env", "value": "test"}], + ex_tier="maxiops", ) self.assertEqual(volume.id, "01d4fcd4-e446-433b-8a9c-551a1284952e") self.assertEqual(volume.name, "data") self.assertEqual(volume.size, 50) - self.assertEqual(volume.extra["encrypted"], "yes") request_storage = UpcloudMockHttp.last_request_body["storage"] - self.assertEqual(request_storage["tier"], "standard") - self.assertEqual(request_storage["encrypted"], "yes") - self.assertEqual(request_storage["labels"], [{"key": "env", "value": "test"}]) + 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): @@ -278,18 +275,26 @@ def test_attach_volume(self): node = self.driver.list_nodes()[0] volume = self.driver.list_volumes()[1] - attached_volume = self.driver.attach_volume(node, volume, device="scsi") + success = self.driver.attach_volume(node, volume, device="scsi") - self.assertIsInstance(attached_volume, StorageVolume) - self.assertEqual(attached_volume.id, volume.id) + self.assertTrue(success) request_device = UpcloudMockHttp.last_request_body["storage_device"] - self.assertEqual(request_device["server"], node.id) + 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] - self.assertTrue(self.driver.detach_volume(volume)) + with self.assertRaises(ValueError): + self.driver.detach_volume(volume) def test_destroy_volume(self): volume = self.driver.list_volumes()[1] @@ -313,24 +318,10 @@ def test_reboot_node(self): def test_start_node(self): nodes = self.driver.list_nodes() - success = self.driver.start_node( - nodes[0], - ex_host=8055964291, - ex_avoid_host=7653311107, - ex_start_type="async", - ) + success = self.driver.start_node(nodes[0]) self.assertTrue(success) - self.assertEqual( - UpcloudMockHttp.last_request_body, - { - "server": { - "host": 8055964291, - "avoid_host": 7653311107, - "start_type": "async", - } - }, - ) + self.assertIsNone(UpcloudMockHttp.last_request_body) def test_stop_node(self): nodes = self.driver.list_nodes() @@ -443,17 +434,18 @@ def _1_2_storage(self, method, url, body, headers): body = self.fixtures.load("api_1_2_storage_create.json") return (httplib.CREATED, body, {}, httplib.responses[httplib.CREATED]) - def _1_2_storage_01d4fcd4_e446_433b_8a9c_551a1284952e_attach( + 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_storage_01d4fcd4-e446-433b-8a9c-551a1284952e.json") + 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_detach( + def _1_2_server_00f8c525_7e62_4108_8115_3958df5b43dc_storage_detach( self, method, url, body, headers ): - body = self.fixtures.load("api_1_2_storage_01d4fcd4-e446-433b-8a9c-551a1284952e.json") + 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): @@ -470,7 +462,7 @@ 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 = json.loads(body) + 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]) From de658406a4a469c58a6c7c0ffcf783a6e7cd0ec9 Mon Sep 17 00:00:00 2001 From: Miguel Caballer Date: Fri, 3 Jul 2026 12:27:53 +0200 Subject: [PATCH 3/7] Fix black --- libcloud/compute/drivers/upcloud.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/libcloud/compute/drivers/upcloud.py b/libcloud/compute/drivers/upcloud.py index 36dbf4eddd..3bdd79cd32 100644 --- a/libcloud/compute/drivers/upcloud.py +++ b/libcloud/compute/drivers/upcloud.py @@ -352,8 +352,7 @@ def detach_volume(self, volume, ex_node=None, ex_address=None): """ 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." + "UpCloud API 1.2 requires `ex_node` and `ex_address` " "when detaching a volume." ) self.connection.request( @@ -560,9 +559,7 @@ def _to_volume(self, volume): name=volume["title"], size=int(volume["size"]), driver=self, - state=self.STORAGE_VOLUME_STATE_MAP.get( - volume["state"], StorageVolumeState.UNKNOWN - ), + state=self.STORAGE_VOLUME_STATE_MAP.get(volume["state"], StorageVolumeState.UNKNOWN), extra=extra, ) From 0f5fb8f4057f0fd4f36f336be76e2d73c35b3c4b Mon Sep 17 00:00:00 2001 From: Miguel Caballer Fernandez Date: Fri, 3 Jul 2026 12:56:19 +0200 Subject: [PATCH 4/7] Fix isort --- libcloud/test/compute/test_upcloud.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libcloud/test/compute/test_upcloud.py b/libcloud/test/compute/test_upcloud.py index a8ea45cf6c..b53d4866c3 100644 --- a/libcloud/test/compute/test_upcloud.py +++ b/libcloud/test/compute/test_upcloud.py @@ -28,8 +28,8 @@ NodeSize, NodeImage, NodeLocation, - NodeAuthSSHKey, StorageVolume, + NodeAuthSSHKey, ) from libcloud.test.secrets import UPCLOUD_PARAMS from libcloud.compute.types import Provider, NodeState From b7c091db232c01bb322d3031b48c5b5ffd9a5e00 Mon Sep 17 00:00:00 2001 From: Miguel Caballer Date: Mon, 6 Jul 2026 10:25:16 +0200 Subject: [PATCH 5/7] Add API key in the driver auth --- docs/compute/drivers/upcloud.rst | 27 ++++++++++++++++++++++-- libcloud/compute/drivers/upcloud.py | 30 ++++++++++++++++++++++----- libcloud/test/compute/test_upcloud.py | 30 +++++++++++++++++++++------ 3 files changed, 74 insertions(+), 13 deletions(-) diff --git a/docs/compute/drivers/upcloud.rst b/docs/compute/drivers/upcloud.rst index ed5b0e9bf9..8308627d22 100644 --- a/docs/compute/drivers/upcloud.rst +++ b/docs/compute/drivers/upcloud.rst @@ -22,12 +22,35 @@ UpCloud currently operates globally from eight (8) data centers: Instantiating a driver ---------------------- -When you instantiate a driver you need to pass the following arguments to the -driver constructor: +When you instantiate a driver you can authenticate with an API bearer token: + +* ``token`` - Your UpCloud API bearer token + +For example: + +.. code-block:: python + + from libcloud.compute.types import Provider + from libcloud.compute.providers import get_driver + + cls = get_driver(Provider.UPCLOUD) + driver = cls(token="ucat_...") + +You can also use basic authentication with an API enabled user: * ``username`` - Your API access enabled users username * ``password`` - Your API access enabled users password +For example: + +.. code-block:: python + + from libcloud.compute.types import Provider + from libcloud.compute.providers import get_driver + + cls = get_driver(Provider.UPCLOUD) + driver = cls("username", "password") + Enabling API access ------------------- diff --git a/libcloud/compute/drivers/upcloud.py b/libcloud/compute/drivers/upcloud.py index 3bdd79cd32..d3880c1677 100644 --- a/libcloud/compute/drivers/upcloud.py +++ b/libcloud/compute/drivers/upcloud.py @@ -65,9 +65,16 @@ class UpcloudConnection(ConnectionUserAndKey): host = "api.upcloud.com" responseCls = UpcloudResponse + def __init__(self, user_id, key, *args, **kwargs): + self.token = kwargs.pop("token", None) + super().__init__(user_id, key, *args, **kwargs) + def add_default_headers(self, headers): """Adds headers that are needed for all requests""" - headers["Authorization"] = self._basic_auth() + if self.token: + headers["Authorization"] = "Bearer {}".format(self.token) + else: + headers["Authorization"] = self._basic_auth() headers["Accept"] = "application/json" headers["Content-Type"] = "application/json" return headers @@ -83,11 +90,14 @@ class UpcloudDriver(NodeDriver): """ Upcloud node driver - :keyword username: Username required for authentication + :keyword username: Username required for basic authentication :type username: ``str`` - :keyword password: Password required for authentication + :keyword password: Password required for basic authentication :type password: ``str`` + + :keyword token: Bearer API token used instead of username/password + :type token: ``str`` """ type = Provider.UPCLOUD @@ -112,8 +122,18 @@ class UpcloudDriver(NodeDriver): "error": StorageVolumeState.ERROR, } - def __init__(self, username, password, **kwargs): - super().__init__(key=username, secret=password, **kwargs) + def __init__(self, username=None, password=None, token=None, **kwargs): + if token is None and (username is None or password is None): + raise ValueError("Must provide either username/password or token.") + + self.token = token + super().__init__(key=username or "", secret=password or "", **kwargs) + + def _ex_connection_class_kwargs(self): + kwargs = super()._ex_connection_class_kwargs() + if self.token: + kwargs["token"] = self.token + return kwargs def list_locations(self): """ diff --git a/libcloud/test/compute/test_upcloud.py b/libcloud/test/compute/test_upcloud.py index b53d4866c3..b588ab219f 100644 --- a/libcloud/test/compute/test_upcloud.py +++ b/libcloud/test/compute/test_upcloud.py @@ -67,6 +67,16 @@ def test_authentication_fails(self): with self.assertRaises(InvalidCredsError): self.driver.list_locations() + def test_authentication_with_api_token(self): + driver = UpcloudDriver(token="test-token") + driver.list_locations() + + self.assertEqual(UpcloudMockHttp.last_authorization, "Bearer test-token") + + def test_authentication_requires_basic_credentials_or_token(self): + with self.assertRaises(ValueError): + UpcloudDriver() + class UpcloudDriverTests(LibcloudTestCase): def setUp(self): @@ -384,16 +394,24 @@ def dicts_equals(self, d1, d2): class UpcloudMockHttp(MockHttp): fixtures = ComputeFileFixtures("upcloud") last_request_body = None + last_authorization = None def _1_2_zone(self, method, url, body, headers): - auth = headers["Authorization"].split(" ")[1] - username, password = ensure_string(base64.b64decode(auth)).split(":") - if username == "nosuchuser" and password == "nopwd": - body = self.fixtures.load("api_1_2_zone_failed_auth.json") - status_code = httplib.UNAUTHORIZED - else: + self.__class__.last_authorization = headers["Authorization"] + auth_type, auth_value = headers["Authorization"].split(" ", 1) + + if auth_type == "Bearer": body = self.fixtures.load("api_1_2_zone.json") status_code = httplib.OK + else: + username, password = ensure_string(base64.b64decode(auth_value)).split(":") + if username == "nosuchuser" and password == "nopwd": + body = self.fixtures.load("api_1_2_zone_failed_auth.json") + status_code = httplib.UNAUTHORIZED + else: + body = self.fixtures.load("api_1_2_zone.json") + status_code = httplib.OK + return (status_code, body, {}, httplib.responses[httplib.OK]) def _1_2_plan(self, method, url, body, headers): From 7a8fa678babaebda807d4ba0f70b4f73e3811af2 Mon Sep 17 00:00:00 2001 From: Miguel Caballer Date: Mon, 6 Jul 2026 11:49:39 +0200 Subject: [PATCH 6/7] Move to new 1.3 API --- docs/compute/drivers/upcloud.rst | 2 + libcloud/common/upcloud.py | 16 ++++-- libcloud/compute/drivers/upcloud.py | 70 +++++++++++++++--------- libcloud/test/common/test_upcloud.py | 13 +++++ libcloud/test/compute/test_upcloud.py | 78 +++++++++++++++++++++------ 5 files changed, 135 insertions(+), 44 deletions(-) diff --git a/docs/compute/drivers/upcloud.rst b/docs/compute/drivers/upcloud.rst index 8308627d22..7d99c3a038 100644 --- a/docs/compute/drivers/upcloud.rst +++ b/docs/compute/drivers/upcloud.rst @@ -19,6 +19,8 @@ UpCloud currently operates globally from eight (8) data centers: * San Jose, USA * Singapore, Singapore +This driver uses the UpCloud API 1.3 endpoints. + Instantiating a driver ---------------------- diff --git a/libcloud/common/upcloud.py b/libcloud/common/upcloud.py index 5b4273da8e..797629cfdc 100644 --- a/libcloud/common/upcloud.py +++ b/libcloud/common/upcloud.py @@ -56,6 +56,11 @@ class UpcloudCreateNodeRequestBody: :param ex_storage_devices: Additional UpCloud storage_device dictionaries. (optional) :type ex_storage_devices: ``list`` of ``dict`` + + :param ex_metadata: Whether to enable the UpCloud metadata service, + ``"yes"`` or ``"no"``. Cloud-init templates require + this to be enabled. (optional) + :type ex_metadata: ``str`` """ def __init__( @@ -68,6 +73,7 @@ def __init__( ex_hostname="localhost", ex_username="root", ex_storage_devices=None, + ex_metadata=None, ): storage_devices = _StorageDevice(image, size).to_dict() if ex_storage_devices: @@ -83,6 +89,8 @@ def __init__( "storage_devices": storage_devices, } } + if ex_metadata is not None: + self.body["server"]["metadata"] = ex_metadata def to_json(self): """ @@ -178,7 +186,7 @@ def stop_node(self, node_id): """ body = {"stop_server": {"stop_type": "hard"}} self.connection.request( - "1.2/server/{}/stop".format(node_id), method="POST", data=json.dumps(body) + "1.3/server/{}/stop".format(node_id), method="POST", data=json.dumps(body) ) def start_node(self, node_id): @@ -188,7 +196,7 @@ def start_node(self, node_id): :param node_id: Id of the Node :type node_id: ``int`` """ - self.connection.request("1.2/server/{}/start".format(node_id), method="POST") + self.connection.request("1.3/server/{}/start".format(node_id), method="POST") def get_node_state(self, node_id): """ @@ -200,7 +208,7 @@ def get_node_state(self, node_id): :rtype: ``str`` """ - action = "1.2/server/{}".format(node_id) + action = "1.3/server/{}".format(node_id) try: response = self.connection.request(action) return response.object["server"]["state"] @@ -216,7 +224,7 @@ def destroy_node(self, node_id): :param node_id: Id of the Node :type node_id: ``int`` """ - self.connection.request("1.2/server/{}".format(node_id), method="DELETE") + self.connection.request("1.3/server/{}".format(node_id), method="DELETE") class PlanPrice: diff --git a/libcloud/compute/drivers/upcloud.py b/libcloud/compute/drivers/upcloud.py index d3880c1677..88b6cf0b4d 100644 --- a/libcloud/compute/drivers/upcloud.py +++ b/libcloud/compute/drivers/upcloud.py @@ -52,9 +52,19 @@ def success(self): def parse_error(self): data = self.parse_body() + error = data.get("error", data) if self.status == httplib.UNAUTHORIZED: - raise InvalidCredsError(value=data["error"]["error_message"]) - return data + raise InvalidCredsError(value=error["error_message"]) + + if isinstance(error, dict): + message = error.get("error_message") + code = error.get("error_code") + if message and code: + return "{}: {}".format(code, message) + if message: + return message + + return json.dumps(data) class UpcloudConnection(ConnectionUserAndKey): @@ -141,7 +151,7 @@ def list_locations(self): :rtype: ``list`` of :class:`NodeLocation` """ - response = self.connection.request("1.2/zone") + response = self.connection.request("1.3/zone") return self._to_node_locations(response.object["zones"]["zone"]) def list_sizes(self, location=None): @@ -155,8 +165,8 @@ def list_sizes(self, location=None): :rtype: ``list`` of :class:`NodeSize` """ - prices_response = self.connection.request("1.2/price") - response = self.connection.request("1.2/plan") + prices_response = self.connection.request("1.3/price") + response = self.connection.request("1.3/plan") return self._to_node_sizes( response.object["plans"]["plan"], prices_response.object["prices"]["zone"], @@ -169,9 +179,9 @@ def list_images(self): :rtype: ``list`` of :class:`NodeImage` """ - response = self.connection.request("1.2/storage/template") + response = self.connection.request("1.3/storage/template") obj = response.object - response = self.connection.request("1.2/storage/cdrom") + response = self.connection.request("1.3/storage/cdrom") storage = response.object["storages"]["storage"] obj["storages"]["storage"].extend(storage) return self._to_node_images(obj["storages"]["storage"]) @@ -186,6 +196,7 @@ def create_node( ex_hostname="localhost", ex_username="root", ex_storage_devices=None, + ex_metadata=None, ): """ Creates instance to upcloud. @@ -225,6 +236,11 @@ def create_node( an extra data disk. (optional) :type ex_storage_devices: ``list`` of ``dict`` + :param ex_metadata: Enable or disable the UpCloud metadata service, + ``"yes"`` or ``"no"``. Cloud-init templates + require this to be enabled. (optional) + :type ex_metadata: ``str`` + :return: The newly created node. :rtype: :class:`.Node` """ @@ -237,8 +253,9 @@ def create_node( ex_hostname=ex_hostname, ex_username=ex_username, ex_storage_devices=ex_storage_devices, + ex_metadata=ex_metadata, ) - response = self.connection.request("1.2/server", method="POST", data=body.to_json()) + response = self.connection.request("1.3/server", method="POST", data=body.to_json()) server = response.object["server"] # Upcloud server's are in maintenance state when going # from state to other, it is safe to assume STARTING state @@ -250,7 +267,7 @@ def list_volumes(self): :rtype: ``list`` of :class:`StorageVolume` """ - response = self.connection.request("1.2/storage/normal") + response = self.connection.request("1.3/storage/normal") return self._to_volumes(response.object["storages"]["storage"]) def create_volume( @@ -300,7 +317,7 @@ def create_volume( storage["backup_rule"] = ex_backup_rule response = self.connection.request( - "1.2/storage", + "1.3/storage", method="POST", data=json.dumps({"storage": storage}), ) @@ -346,7 +363,7 @@ def attach_volume( storage_device["address"] = device self.connection.request( - "1.2/server/{}/storage/attach".format(node.id), + "1.3/server/{}/storage/attach".format(node.id), method="POST", data=json.dumps({"storage_device": storage_device}), ) @@ -360,11 +377,11 @@ def detach_volume(self, volume, ex_node=None, ex_address=None): :type volume: :class:`StorageVolume` :param ex_node: Node where the volume is attached. Required by the - UpCloud 1.2 detach endpoint. + UpCloud 1.3 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 + ``scsi:0:0``. Required by the UpCloud 1.3 detach endpoint. :type ex_address: ``str`` @@ -372,11 +389,11 @@ def detach_volume(self, volume, ex_node=None, ex_address=None): """ 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." + "UpCloud API 1.3 requires `ex_node` and `ex_address` " "when detaching a volume." ) self.connection.request( - "1.2/server/{}/storage/detach".format(ex_node.id), + "1.3/server/{}/storage/detach".format(ex_node.id), method="POST", data=json.dumps({"storage_device": {"address": ex_address}}), ) @@ -391,7 +408,7 @@ def destroy_volume(self, volume): :rtype: ``bool`` """ - self.connection.request("1.2/storage/{}".format(volume.id), method="DELETE") + self.connection.request("1.3/storage/{}".format(volume.id), method="DELETE") return True def list_nodes(self): @@ -403,7 +420,7 @@ def list_nodes(self): """ servers = [] for nid in self._node_ids(): - response = self.connection.request("1.2/server/{}".format(nid)) + response = self.connection.request("1.3/server/{}".format(nid)) servers.append(response.object["server"]) return self._to_nodes(servers) @@ -418,7 +435,7 @@ def reboot_node(self, node): """ body = {"restart_server": {"stop_type": "hard"}} self.connection.request( - "1.2/server/{}/restart".format(node.id), + "1.3/server/{}/restart".format(node.id), method="POST", data=json.dumps(body), ) @@ -433,7 +450,7 @@ def start_node(self, node): :rtype: ``bool`` """ - self.connection.request("1.2/server/{}/start".format(node.id), method="POST") + self.connection.request("1.3/server/{}/start".format(node.id), method="POST") return True def stop_node(self, node, ex_stop_type="hard", ex_timeout=None): @@ -459,7 +476,7 @@ def stop_node(self, node, ex_stop_type="hard", ex_timeout=None): stop_server["timeout"] = ex_timeout self.connection.request( - "1.2/server/{}/stop".format(node.id), + "1.3/server/{}/stop".format(node.id), method="POST", data=json.dumps({"stop_server": stop_server}), ) @@ -485,7 +502,7 @@ def _node_ids(self): """ Returns list of server uids currently on upcloud """ - response = self.connection.request("1.2/server") + response = self.connection.request("1.3/server") servers = response.object["servers"]["server"] return [server["uuid"] for server in servers] @@ -497,9 +514,7 @@ def _to_node(self, server, state=None): public_ips = [ip["address"] for ip in ip_addresses if ip["access"] == "public"] private_ips = [ip["address"] for ip in ip_addresses if ip["access"] == "private"] - extra = {"vnc_password": server["vnc_password"]} - if "password" in server: - extra["password"] = server["password"] + extra = self._copy_dict_if_present(("password", "vnc_password"), server) return Node( id=server["uuid"], name=server["title"], @@ -588,3 +603,10 @@ def _copy_dict(self, keys, d): for key in keys: extra[key] = d[key] return extra + + def _copy_dict_if_present(self, keys, d): + extra = {} + for key in keys: + if key in d: + extra[key] = d[key] + return extra diff --git a/libcloud/test/common/test_upcloud.py b/libcloud/test/common/test_upcloud.py index 674ebdde9b..9400bbe3f5 100644 --- a/libcloud/test/common/test_upcloud.py +++ b/libcloud/test/common/test_upcloud.py @@ -197,6 +197,19 @@ def test_creating_node_with_non_default_username(self): login_user = dict_body["server"]["login_user"] self.assertDictEqual({"username": "someone", "create_password": "yes"}, login_user) + def test_creating_node_with_metadata_enabled(self): + body = UpcloudCreateNodeRequestBody( + name="ts", + image=self.image, + location=self.location, + size=self.size, + ex_metadata="yes", + ) + json_body = body.to_json() + dict_body = json.loads(json_body) + + self.assertEqual(dict_body["server"]["metadata"], "yes") + class TestStorageDevice(unittest.TestCase): def setUp(self): diff --git a/libcloud/test/compute/test_upcloud.py b/libcloud/test/compute/test_upcloud.py index b588ab219f..b39061d053 100644 --- a/libcloud/test/compute/test_upcloud.py +++ b/libcloud/test/compute/test_upcloud.py @@ -248,6 +248,35 @@ def test_create_node_with_extra_storage_devices(self): self.assertEqual(len(storage_devices), 2) self.assertEqual(storage_devices[1], extra_storage) + def test_create_node_with_metadata_enabled(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, + ) + + self.driver.create_node( + name="test_server", + size=size, + image=image, + location=location, + ex_metadata="yes", + ) + + self.assertEqual(UpcloudMockHttp.last_request_body["server"]["metadata"], "yes") + def test_list_volumes(self): volumes = self.driver.list_volumes() self.assertEqual(len(volumes), 2) @@ -321,6 +350,23 @@ def test_list_nodes(self): self.assertTrue(len(node.private_ips) > 0) self.assertEqual(node.driver, self.driver) + def test_to_node_without_vnc_password(self): + server = { + "uuid": "00f8c525-7e62-4108-8115-3958df5b43dc", + "title": "test_server", + "state": "started", + "ip_addresses": { + "ip_address": [ + {"access": "public", "address": "203.0.113.10"}, + {"access": "private", "address": "10.0.0.10"}, + ] + }, + } + + node = self.driver._to_node(server) + + self.assertEqual(node.extra, {}) + def test_reboot_node(self): nodes = self.driver.list_nodes() success = self.driver.reboot_node(nodes[0]) @@ -396,7 +442,7 @@ class UpcloudMockHttp(MockHttp): last_request_body = None last_authorization = None - def _1_2_zone(self, method, url, body, headers): + def _1_3_zone(self, method, url, body, headers): self.__class__.last_authorization = headers["Authorization"] auth_type, auth_value = headers["Authorization"].split(" ", 1) @@ -414,23 +460,23 @@ def _1_2_zone(self, method, url, body, headers): return (status_code, body, {}, httplib.responses[httplib.OK]) - def _1_2_plan(self, method, url, body, headers): + def _1_3_plan(self, method, url, body, headers): body = self.fixtures.load("api_1_2_plan.json") return (httplib.OK, body, {}, httplib.responses[httplib.OK]) - def _1_2_storage_cdrom(self, method, url, body, headers): + def _1_3_storage_cdrom(self, method, url, body, headers): body = self.fixtures.load("api_1_2_storage_cdrom.json") return (httplib.OK, body, {}, httplib.responses[httplib.OK]) - def _1_2_storage_template(self, method, url, body, headers): + def _1_3_storage_template(self, method, url, body, headers): body = self.fixtures.load("api_1_2_storage_template.json") return (httplib.OK, body, {}, httplib.responses[httplib.OK]) - def _1_2_price(self, method, url, body, headers): + def _1_3_price(self, method, url, body, headers): body = self.fixtures.load("api_1_2_price.json") return (httplib.OK, body, {}, httplib.responses[httplib.OK]) - def _1_2_server(self, method, url, body, headers): + def _1_3_server(self, method, url, body, headers): if method == "POST": dbody = json.loads(body) self.__class__.last_request_body = dbody @@ -443,53 +489,53 @@ 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): + def _1_3_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): + def _1_3_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( + def _1_3_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( + def _1_3_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): + def _1_3_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): + def _1_3_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]) - def _1_2_server_00f8c525_7e62_4108_8115_3958df5b43dc_restart(self, method, url, body, headers): + def _1_3_server_00f8c525_7e62_4108_8115_3958df5b43dc_restart(self, method, url, body, headers): body = self.fixtures.load( "api_1_2_server_00f8c525-7e62-4108-8115-3958df5b43dc_restart.json" ) return (httplib.OK, body, {}, httplib.responses[httplib.OK]) - def _1_2_server_00f8c525_7e62_4108_8115_3958df5b43dc_start(self, method, url, body, headers): + def _1_3_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): + def _1_3_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): + def _1_3_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]) From aee52db771d04f0b6c4d62f686cae6d7426fe693 Mon Sep 17 00:00:00 2001 From: Miguel Caballer Date: Mon, 6 Jul 2026 12:10:23 +0200 Subject: [PATCH 7/7] Add firewall functions --- docs/compute/drivers/upcloud.rst | 13 +++ libcloud/compute/drivers/upcloud.py | 88 ++++++++++++++++++ libcloud/test/compute/test_upcloud.py | 127 ++++++++++++++++++++++++++ 3 files changed, 228 insertions(+) diff --git a/docs/compute/drivers/upcloud.rst b/docs/compute/drivers/upcloud.rst index 7d99c3a038..6cc8888a3f 100644 --- a/docs/compute/drivers/upcloud.rst +++ b/docs/compute/drivers/upcloud.rst @@ -21,6 +21,19 @@ UpCloud currently operates globally from eight (8) data centers: This driver uses the UpCloud API 1.3 endpoints. +Firewall rules +-------------- + +UpCloud manages network access through per-server firewall rules instead of +reusable security groups. The driver exposes those rules through extension +methods: + +* ``ex_list_firewall_rules(node)`` +* ``ex_get_firewall_rule(node, position)`` +* ``ex_create_firewall_rule(node, rule)`` +* ``ex_create_firewall_rules(node, rules)`` +* ``ex_delete_firewall_rule(node, position)`` + Instantiating a driver ---------------------- diff --git a/libcloud/compute/drivers/upcloud.py b/libcloud/compute/drivers/upcloud.py index 88b6cf0b4d..843e44fd2e 100644 --- a/libcloud/compute/drivers/upcloud.py +++ b/libcloud/compute/drivers/upcloud.py @@ -411,6 +411,94 @@ def destroy_volume(self, volume): self.connection.request("1.3/storage/{}".format(volume.id), method="DELETE") return True + def ex_list_firewall_rules(self, node): + """ + List firewall rules for a node. + + :param node: Node whose firewall rules are listed. + :type node: :class:`Node` + + :rtype: ``list`` of ``dict`` + """ + response = self.connection.request("1.3/server/{}/firewall_rule".format(node.id)) + rules = response.object["firewall_rules"].get("firewall_rule", []) + if isinstance(rules, dict): + return [rules] + return rules + + def ex_get_firewall_rule(self, node, position): + """ + Get firewall rule details by rule position. + + :param node: Node whose firewall rule is fetched. + :type node: :class:`Node` + + :param position: Firewall rule position. + :type position: ``str`` or ``int`` + + :rtype: ``dict`` + """ + response = self.connection.request( + "1.3/server/{}/firewall_rule/{}".format(node.id, position) + ) + return response.object["firewall_rule"] + + def ex_create_firewall_rule(self, node, rule): + """ + Create a firewall rule for a node. + + :param node: Node where the firewall rule is created. + :type node: :class:`Node` + + :param rule: UpCloud firewall rule dictionary. + :type rule: ``dict`` + + :rtype: ``dict`` + """ + response = self.connection.request( + "1.3/server/{}/firewall_rule".format(node.id), + method="POST", + data=json.dumps({"firewall_rule": rule}), + ) + return response.object["firewall_rule"] + + def ex_create_firewall_rules(self, node, rules): + """ + Replace firewall rules for a node. + + :param node: Node whose firewall rules are replaced. + :type node: :class:`Node` + + :param rules: UpCloud firewall rule dictionaries. + :type rules: ``list`` of ``dict`` + + :rtype: ``bool`` + """ + self.connection.request( + "1.3/server/{}/firewall_rule".format(node.id), + method="PUT", + data=json.dumps({"firewall_rules": {"firewall_rule": rules}}), + ) + return True + + def ex_delete_firewall_rule(self, node, position): + """ + Delete a firewall rule by rule position. + + :param node: Node whose firewall rule is deleted. + :type node: :class:`Node` + + :param position: Firewall rule position. + :type position: ``str`` or ``int`` + + :rtype: ``bool`` + """ + self.connection.request( + "1.3/server/{}/firewall_rule/{}".format(node.id, position), + method="DELETE", + ) + return True + def list_nodes(self): """ List nodes diff --git a/libcloud/test/compute/test_upcloud.py b/libcloud/test/compute/test_upcloud.py index b39061d053..aaa585ddeb 100644 --- a/libcloud/test/compute/test_upcloud.py +++ b/libcloud/test/compute/test_upcloud.py @@ -339,6 +339,71 @@ def test_destroy_volume(self): volume = self.driver.list_volumes()[1] self.assertTrue(self.driver.destroy_volume(volume)) + def test_ex_list_firewall_rules(self): + node = self.driver.list_nodes()[0] + + rules = self.driver.ex_list_firewall_rules(node) + + self.assertEqual(len(rules), 2) + self.assertEqual(rules[0]["direction"], "in") + self.assertEqual(rules[0]["destination_port_start"], "22") + + def test_ex_get_firewall_rule(self): + node = self.driver.list_nodes()[0] + + rule = self.driver.ex_get_firewall_rule(node, 1) + + self.assertEqual(rule["position"], "1") + self.assertEqual(rule["action"], "accept") + + def test_ex_create_firewall_rule(self): + node = self.driver.list_nodes()[0] + rule = { + "direction": "in", + "family": "IPv4", + "protocol": "tcp", + "destination_port_start": "22", + "destination_port_end": "22", + "action": "accept", + "comment": "Allow SSH", + } + + created_rule = self.driver.ex_create_firewall_rule(node, rule) + + self.assertEqual(created_rule["position"], "1") + self.assertEqual( + UpcloudMockHttp.last_request_body, + {"firewall_rule": rule}, + ) + + def test_ex_create_firewall_rules(self): + node = self.driver.list_nodes()[0] + rules = [ + { + "direction": "in", + "family": "IPv4", + "protocol": "tcp", + "destination_port_start": "22", + "destination_port_end": "22", + "action": "accept", + "comment": "Allow SSH", + }, + {"direction": "in", "action": "drop"}, + ] + + success = self.driver.ex_create_firewall_rules(node, rules) + + self.assertTrue(success) + self.assertEqual( + UpcloudMockHttp.last_request_body, + {"firewall_rules": {"firewall_rule": rules}}, + ) + + def test_ex_delete_firewall_rule(self): + node = self.driver.list_nodes()[0] + + self.assertTrue(self.driver.ex_delete_firewall_rule(node, 1)) + def test_list_nodes(self): nodes = self.driver.list_nodes() @@ -515,6 +580,68 @@ def _1_3_server_00f8c525_7e62_4108_8115_3958df5b43dc_storage_detach( def _1_3_storage_01d4fcd4_e446_433b_8a9c_551a1284952e(self, method, url, body, headers): return (httplib.NO_CONTENT, "", {}, httplib.responses[httplib.NO_CONTENT]) + def _1_3_server_00f8c525_7e62_4108_8115_3958df5b43dc_firewall_rule( + self, method, url, body, headers + ): + firewall_rule = { + "action": "accept", + "comment": "Allow SSH", + "destination_address_end": "", + "destination_address_start": "", + "destination_port_end": "22", + "destination_port_start": "22", + "direction": "in", + "family": "IPv4", + "icmp_type": "", + "position": "1", + "protocol": "tcp", + "source_address_end": "", + "source_address_start": "", + "source_port_end": "", + "source_port_start": "", + } + if method == "GET": + body = json.dumps( + { + "firewall_rules": { + "firewall_rule": [ + firewall_rule, + {"action": "drop", "direction": "in", "position": "2"}, + ] + } + } + ) + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + if method == "POST": + self.__class__.last_request_body = json.loads(body) + body = json.dumps({"firewall_rule": firewall_rule}) + return (httplib.CREATED, body, {}, httplib.responses[httplib.CREATED]) + + self.__class__.last_request_body = json.loads(body) + return (httplib.NO_CONTENT, "", {}, httplib.responses[httplib.NO_CONTENT]) + + def _1_3_server_00f8c525_7e62_4108_8115_3958df5b43dc_firewall_rule_1( + self, method, url, body, headers + ): + if method == "DELETE": + return (httplib.NO_CONTENT, "", {}, httplib.responses[httplib.NO_CONTENT]) + + body = json.dumps( + { + "firewall_rule": { + "action": "accept", + "comment": "Allow SSH", + "destination_port_end": "22", + "destination_port_start": "22", + "direction": "in", + "family": "IPv4", + "position": "1", + "protocol": "tcp", + } + } + ) + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + def _1_3_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])