From 5a9b157d2158dbac1896462b7ee373cdfa22edc4 Mon Sep 17 00:00:00 2001 From: Michael Galaxy Date: Mon, 7 Aug 2023 11:53:05 -0400 Subject: [PATCH] Updated Linode (Akamai Connected Cloud) support (including cloud-init) 1. cloud-init support is a new feature available in 2023. 2. The main entry point (create_node) had an arrangement of non-keyword parameters that were not consistent with other libcloud drivers. This has been fixed. 3. One remaining function (already available in the API) was exposed to also be consistent with other drivers. --- CHANGES.rst | 5 ++ .../_supported_methods_image_management.rst | 2 +- ..._supported_methods_key_pair_management.rst | 2 +- docs/upgrade_notes.rst | 6 ++ libcloud/compute/drivers/linode.py | 80 ++++++++++++++++++- .../fixtures/linode_v4/create_key_pair.json | 6 ++ .../fixtures/linode_v4/list_key_pairs.json | 13 +++ libcloud/test/compute/test_linode_v4.py | 43 +++++++--- 8 files changed, 141 insertions(+), 16 deletions(-) create mode 100644 libcloud/test/compute/fixtures/linode_v4/create_key_pair.json create mode 100644 libcloud/test/compute/fixtures/linode_v4/list_key_pairs.json diff --git a/CHANGES.rst b/CHANGES.rst index fbf2386d2a..228acb4368 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -67,6 +67,11 @@ Compute (#1906) [Ross Vandegrift - @rvandegrift] +- [LINODE] Add support for cloud-init metadata support to create_node() + Add new functions ``create_key_pair``, ``list_key_pairs``, and ``get_image`` + (#1946) + [Michael Galaxy - @mraygalaxy2] + Storage ~~~~~~~ diff --git a/docs/compute/_supported_methods_image_management.rst b/docs/compute/_supported_methods_image_management.rst index c121c6ba7f..7c0bdf9eda 100644 --- a/docs/compute/_supported_methods_image_management.rst +++ b/docs/compute/_supported_methods_image_management.rst @@ -28,7 +28,7 @@ Provider list images get image create image delete image c `KTUCloud`_ yes no no no no `kubevirt`_ yes no no no no `Libvirt`_ no no no no no -`Linode`_ yes no yes yes no +`Linode`_ yes yes yes yes no `Maxihost`_ yes no no no no `Nimbus`_ yes yes yes yes yes `NTTAmerica`_ yes no no no no diff --git a/docs/compute/_supported_methods_key_pair_management.rst b/docs/compute/_supported_methods_key_pair_management.rst index ef255a28fa..ddfed43900 100644 --- a/docs/compute/_supported_methods_key_pair_management.rst +++ b/docs/compute/_supported_methods_key_pair_management.rst @@ -28,7 +28,7 @@ Provider list key pairs get key pair create key pair impor `KTUCloud`_ yes yes yes yes no yes `kubevirt`_ no no no no no no `Libvirt`_ no no no no no no -`Linode`_ no no no no no no +`Linode`_ yes no yes no no no `Maxihost`_ yes no yes no no no `Nimbus`_ yes yes yes yes no yes `NTTAmerica`_ no no no no no no diff --git a/docs/upgrade_notes.rst b/docs/upgrade_notes.rst index 0f7fc359d4..d97cdaca24 100644 --- a/docs/upgrade_notes.rst +++ b/docs/upgrade_notes.rst @@ -5,6 +5,12 @@ This page describes how to upgrade from a previous version to a new version which contains backward incompatible or semi-incompatible changes and how to preserve the old behavior when this is possible. +Libcloud 3.8.0 +-------------- +- [LINODE API v4] Order of arguments to create_node() was changed. The order of the + arguments for name and size were not consistent with the rest of the codebase. + This is possibly a breaking change for anyone using a previous version. + Libcloud 3.7.0 -------------- diff --git a/libcloud/compute/drivers/linode.py b/libcloud/compute/drivers/linode.py index bd213935d0..0b08bca47e 100644 --- a/libcloud/compute/drivers/linode.py +++ b/libcloud/compute/drivers/linode.py @@ -37,6 +37,7 @@ from libcloud.utils.py3 import httplib from libcloud.compute.base import ( Node, + KeyPair, NodeSize, NodeImage, NodeDriver, @@ -861,6 +862,33 @@ def list_images(self): data = self._paginated_request("/v4/images", "data") return [self._to_image(obj) for obj in data] + def create_key_pair(self, name, public_key=""): + """ + Creates an SSH keypair + + :param name: The name to be given to the keypair (required).\ + :type name: `str` + + :keyword public_key: Contents of the public key the the SSH key pair + :type public_key: `str` + + :rtype: :class: `KeyPair` + """ + attr = {"label": name, "ssh_key": public_key} + response = self.connection.request( + "/v4/profile/sshkeys", data=json.dumps(attr), method="POST" + ).object + return self._to_key_pair(response) + + def list_key_pairs(self): + """ + Provide a list of all the SSH keypairs in your account. + + :rtype: ``list`` of :class: `KeyPair` + """ + data = self._paginated_request("/v4/profile/sshkeys", "data") + return [self._to_key_pair(obj) for obj in data] + def list_locations(self): """ Lists the Regions available for Linode services @@ -945,15 +973,28 @@ def reboot_node(self, node): def create_node( self, location, - size, - image=None, - name=None, + # Previously, the following 3 parameters did not match the rest of the libcloud + # codebase drivers. They should be in the same order as other compute drivers. + # Previously, it looked like this: + # size, + # image=None, + # name=None, + # + # Comments welcome on how backwards compatibility (if any) should work here. + # Since it was not compatible with other drivers, it is not clear to me if this + # would break anyone's codebase if they were not using any other libcloud drivers + # to other cloud providers in the first place. If they were not, that seems to + # kind of defeat the purpose of using libcloud. + name, # Can be None + size, # Can be None + image, # Can be None root_pass=None, ex_authorized_keys=None, ex_authorized_users=None, ex_tags=None, ex_backups_enabled=False, ex_private_ip=False, + ex_userdata=False, ): """Creates a Linode Instance. In order for this request to complete successfully, @@ -997,6 +1038,12 @@ def create_node( :keyword ex_private_ip: whether or not to request a private IP :type ex_private_ip: ``bool`` + :keyword ex_userdata: add cloud-config compatible userdata to be + processed by cloud-init inside the Linode instance. NOTE: the + contents of this string must be base64 encoded before passing + it to this function. + :type ex_userdata: ``str`` + :return: Node representing the newly-created node :rtype: :class:`Node` """ @@ -1014,6 +1061,9 @@ def create_node( "backups_enabled": ex_backups_enabled, } + if ex_userdata: + attr["metadata"] = {"user_data": binascii.b2a_base64(bytes(ex_userdata.encode("utf-8"))).decode("ascii").strip()} + if image is not None: if root_pass is None: raise LinodeExceptionV4("root password required " "when providing an image") @@ -1369,6 +1419,18 @@ def ex_get_volume(self, volume_id): response = self.connection.request("/v4/volumes/%s" % volume_id).object return self._to_volume(response) + def get_image(self, image): + """ + Lookup a Linode image + + :param image: The name to image to be looked up (required).\ + :type name: `str` + + :rtype: :class: `NodeImage` + """ + response = self.connection.request("/v4/images/%s" % image, method="GET") + return self._to_image(response.object) + def create_image(self, disk, name=None, description=None): """Creates a private image from a LinodeDisk. Images are limited to three per account. @@ -1554,6 +1616,18 @@ def ex_rename_node(self, node, name): return self._to_node(response) + def _to_key_pair(self, data): + extra = {"id": data["id"]} + + return KeyPair( + name=data["label"], + fingerprint=None, + public_key=data["ssh_key"], + private_key=None, + driver=self, + extra=extra, + ) + def _to_node(self, data): extra = { "tags": data["tags"], diff --git a/libcloud/test/compute/fixtures/linode_v4/create_key_pair.json b/libcloud/test/compute/fixtures/linode_v4/create_key_pair.json new file mode 100644 index 0000000000..58572e4dd6 --- /dev/null +++ b/libcloud/test/compute/fixtures/linode_v4/create_key_pair.json @@ -0,0 +1,6 @@ +{ + "created": "2018-01-01T00:01:01", + "id": 42, + "label": "My SSH Key", + "ssh_key": "ssh-rsa AAAA_valid_public_ssh_key_123456785== user@their-computer" +} diff --git a/libcloud/test/compute/fixtures/linode_v4/list_key_pairs.json b/libcloud/test/compute/fixtures/linode_v4/list_key_pairs.json new file mode 100644 index 0000000000..c476b05bd7 --- /dev/null +++ b/libcloud/test/compute/fixtures/linode_v4/list_key_pairs.json @@ -0,0 +1,13 @@ +{ + "data": [ + { + "created": "2018-01-01T00:01:01", + "id": 42, + "label": "My SSH Key", + "ssh_key": "ssh-rsa AAAA_valid_public_ssh_key_123456785== user@their-computer" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/libcloud/test/compute/test_linode_v4.py b/libcloud/test/compute/test_linode_v4.py index 01b7fb1449..c4c5c8dd5d 100644 --- a/libcloud/test/compute/test_linode_v4.py +++ b/libcloud/test/compute/test_linode_v4.py @@ -20,7 +20,7 @@ from libcloud.test import MockHttp from libcloud.utils.py3 import httplib from libcloud.common.types import LibcloudError, InvalidCredsError -from libcloud.compute.base import Node, NodeImage, NodeState, StorageVolume +from libcloud.compute.base import Node, KeyPair, NodeImage, NodeState, StorageVolume from libcloud.test.compute import TestCaseMixin from libcloud.common.linode import LinodeDisk, LinodeIPAddress, LinodeExceptionV4 from libcloud.test.file_fixtures import ComputeFileFixtures @@ -64,6 +64,19 @@ def test_list_images(self): self.assertIsInstance(image.extra["size"], int) self.assertTrue(image.extra["is_public"]) + def test_list_key_pairs(self): + keypairs = self.driver.list_key_pairs() + self.assertIsInstance(keypairs, list) + self.assertEqual(len(keypairs), 1) + self.assertEqual(keypairs[0].extra["id"], 42) + + def test_create_key_pair(self): + keypair = self.driver.create_key_pair( + "mykey", public_key="ssh-rsa AAAA_valid_public_ssh_key_123456785== user@their-computer" + ) + self.assertIsInstance(keypair, KeyPair) + self.assertEqual(keypair.extra["id"], 42) + def test_list_locations(self): locations = self.driver.list_locations() self.assertEqual(len(locations), 10) @@ -78,11 +91,11 @@ def test_create_node_response(self): image = self.driver.list_images()[0] location = self.driver.list_locations()[0] node = self.driver.create_node( - location=location, - name="node-name", + location, + "node-name", + size=size, image=image, root_pass="test123456", - size=size, ) self.assertTrue(isinstance(node, Node)) @@ -114,9 +127,9 @@ def test_create_node(self): node = self.driver.create_node( location, + "TestNode", size, image=image, - name="TestNode", root_pass="test123456", ex_backups_enabled=True, ex_tags=["testing123"], @@ -134,13 +147,13 @@ def test_create_node_no_root_pass(self): location = self.driver.list_locations()[0] with self.assertRaises(LinodeExceptionV4): - self.driver.create_node(location, size, image=image, name="TestNode") + self.driver.create_node(location, "TestNode", size, image=image) def test_create_node_no_image(self): size = self.driver.list_sizes()[0] location = self.driver.list_locations()[0] LinodeMockHttpV4.type = "NO_IMAGE" - node = self.driver.create_node(location, size, name="TestNode", ex_tags=["testing123"]) + node = self.driver.create_node(location, "TestNode", size, None, ex_tags=["testing123"]) self.assertIsNone(node.image) self.assertEqual(node.name, "TestNode") @@ -153,13 +166,13 @@ def test_create_node_invalid_name(self): location = self.driver.list_locations()[0] with self.assertRaises(LinodeExceptionV4): - self.driver.create_node(location, size, name="Test__Node") + self.driver.create_node(location, "Test__Node", size, None) with self.assertRaises(LinodeExceptionV4): - self.driver.create_node(location, size, name="Test Node") + self.driver.create_node(location, "Test Node", size, None) with self.assertRaises(LinodeExceptionV4): - self.driver.create_node(location, size, name="Test--Node") + self.driver.create_node(location, "Test--Node", size, None) with self.assertRaises(LinodeExceptionV4): - self.driver.create_node(location, size, name="Test..Node") + self.driver.create_node(location, "Test..Node", size, None) def test_reboot_node(self): node = Node("22344420", None, None, None, None, driver=self.driver) @@ -424,6 +437,14 @@ def test__paginated_request_two_pages(self): class LinodeMockHttpV4(MockHttp): fixtures = ComputeFileFixtures("linode_v4") + def _v4_profile_sshkeys(self, method, url, body, headers): + if method == "GET": + body = self.fixtures.load("list_key_pairs.json") + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + if method == "POST": + body = self.fixtures.load("create_key_pair.json") + return (httplib.OK, body, {}, httplib.responses[httplib.OK]) + def _v4_regions(self, method, url, body, headers): body = self.fixtures.load("list_locations.json") return (httplib.OK, body, {}, httplib.responses[httplib.OK])