diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index 5601c855c..da3ba501d 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -1,7 +1,9 @@ import base64 import os from collections.abc import Iterable +from typing import Optional, Union +from linode_api4 import InstanceDiskEncryptionType from linode_api4.common import load_and_validate_keys from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group @@ -128,7 +130,15 @@ def kernels(self, *filters): # create things def instance_create( - self, ltype, region, image=None, authorized_keys=None, **kwargs + self, + ltype, + region, + image=None, + authorized_keys=None, + disk_encryption: Optional[ + Union[InstanceDiskEncryptionType, str] + ] = None, + **kwargs, ): """ Creates a new Linode Instance. This function has several modes of operation: @@ -263,6 +273,9 @@ def instance_create( :type metadata: dict :param firewall: The firewall to attach this Linode to. :type firewall: int or Firewall + :param disk_encryption: The disk encryption policy for this Linode. + NOTE: Disk encryption may not currently be available to all users. + :type disk_encryption: InstanceDiskEncryptionType or str :param interfaces: An array of Network Interfaces to add to this Linode’s Configuration Profile. At least one and up to three Interface objects can exist in this array. :type interfaces: list[ConfigInterface] or list[dict[str, Any]] @@ -330,6 +343,9 @@ def instance_create( "authorized_keys": authorized_keys, } + if disk_encryption is not None: + params["disk_encryption"] = str(disk_encryption) + params.update(kwargs) result = self.client.post("/linode/instances", data=params) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 1b102da37..0e43f1567 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -22,12 +22,25 @@ from linode_api4.objects.base import MappedObject from linode_api4.objects.filtering import FilterableAttribute from linode_api4.objects.networking import IPAddress, IPv6Range, VPCIPAddress +from linode_api4.objects.serializable import StrEnum from linode_api4.objects.vpc import VPC, VPCSubnet from linode_api4.paginated_list import PaginatedList PASSWORD_CHARS = string.ascii_letters + string.digits + string.punctuation +class InstanceDiskEncryptionType(StrEnum): + """ + InstanceDiskEncryptionType defines valid values for the + Instance(...).disk_encryption field. + + API Documentation: TODO + """ + + enabled = "enabled" + disabled = "disabled" + + class Backup(DerivedBase): """ A Backup of a Linode Instance. @@ -114,6 +127,7 @@ class Disk(DerivedBase): "filesystem": Property(), "updated": Property(is_datetime=True), "linode_id": Property(identifier=True), + "disk_encryption": Property(), } def duplicate(self): @@ -662,6 +676,8 @@ class Instance(Base): "host_uuid": Property(), "watchdog_enabled": Property(mutable=True), "has_user_data": Property(), + "disk_encryption": Property(), + "lke_cluster_id": Property(), } @property @@ -1391,7 +1407,16 @@ def ip_allocate(self, public=False): i = IPAddress(self._client, result["address"], result) return i - def rebuild(self, image, root_pass=None, authorized_keys=None, **kwargs): + def rebuild( + self, + image, + root_pass=None, + authorized_keys=None, + disk_encryption: Optional[ + Union[InstanceDiskEncryptionType, str] + ] = None, + **kwargs, + ): """ Rebuilding an Instance deletes all existing Disks and Configs and deploys a new :any:`Image` to it. This can be used to reset an existing @@ -1409,6 +1434,9 @@ def rebuild(self, image, root_pass=None, authorized_keys=None, **kwargs): be a single key, or a path to a file containing the key. :type authorized_keys: list or str + :param disk_encryption: The disk encryption policy for this Linode. + NOTE: Disk encryption may not currently be available to all users. + :type disk_encryption: InstanceDiskEncryptionType or str :returns: The newly generated password, if one was not provided (otherwise True) @@ -1426,6 +1454,10 @@ def rebuild(self, image, root_pass=None, authorized_keys=None, **kwargs): "root_pass": root_pass, "authorized_keys": authorized_keys, } + + if disk_encryption is not None: + params["disk_encryption"] = str(disk_encryption) + params.update(kwargs) result = self._client.post( @@ -1755,6 +1787,22 @@ def stats(self): "{}/stats".format(Instance.api_endpoint), model=self ) + @property + def lke_cluster(self) -> Optional["LKECluster"]: + """ + Returns the LKE Cluster this Instance is a node of. + + :returns: The LKE Cluster this Instance is a node of. + :rtype: Optional[LKECluster] + """ + + # Local import to prevent circular dependency + from linode_api4.objects.lke import ( # pylint: disable=import-outside-toplevel + LKECluster, + ) + + return LKECluster(self._client, self.lke_cluster_id) + def stats_for(self, dt): """ Returns stats for the month containing the given datetime diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index 09a589355..14de05f45 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -132,6 +132,7 @@ class LKENodePool(DerivedBase): "cluster_id": Property(identifier=True), "type": Property(slug_relationship=Type), "disks": Property(), + "disk_encryption": Property(), "count": Property(mutable=True), "nodes": Property( volatile=True diff --git a/test/fixtures/linode_instances.json b/test/fixtures/linode_instances.json index a991c1c4d..38a3cf912 100644 --- a/test/fixtures/linode_instances.json +++ b/test/fixtures/linode_instances.json @@ -41,6 +41,8 @@ "tags": ["something"], "host_uuid": "3a3ddd59d9a78bb8de041391075df44de62bfec8", "watchdog_enabled": true, + "disk_encryption": "disabled", + "lke_cluster_id": null, "placement_group": { "id": 123, "label": "test", @@ -86,6 +88,8 @@ "tags": [], "host_uuid": "3a3ddd59d9a78bb8de041391075df44de62bfec8", "watchdog_enabled": false, + "disk_encryption": "enabled", + "lke_cluster_id": 18881, "placement_group": null } ] diff --git a/test/fixtures/linode_instances_123_disks.json b/test/fixtures/linode_instances_123_disks.json index eca5079e5..ddfe7f313 100644 --- a/test/fixtures/linode_instances_123_disks.json +++ b/test/fixtures/linode_instances_123_disks.json @@ -10,7 +10,8 @@ "id": 12345, "updated": "2017-01-01T00:00:00", "label": "Ubuntu 17.04 Disk", - "created": "2017-01-01T00:00:00" + "created": "2017-01-01T00:00:00", + "disk_encryption": "disabled" }, { "size": 512, @@ -19,7 +20,8 @@ "id": 12346, "updated": "2017-01-01T00:00:00", "label": "512 MB Swap Image", - "created": "2017-01-01T00:00:00" + "created": "2017-01-01T00:00:00", + "disk_encryption": "disabled" } ] } diff --git a/test/fixtures/linode_instances_123_disks_12345_clone.json b/test/fixtures/linode_instances_123_disks_12345_clone.json index 2d378edca..899833e56 100644 --- a/test/fixtures/linode_instances_123_disks_12345_clone.json +++ b/test/fixtures/linode_instances_123_disks_12345_clone.json @@ -5,6 +5,7 @@ "id": 12345, "updated": "2017-01-01T00:00:00", "label": "Ubuntu 17.04 Disk", - "created": "2017-01-01T00:00:00" + "created": "2017-01-01T00:00:00", + "disk_encryption": "disabled" } \ No newline at end of file diff --git a/test/fixtures/lke_clusters_18881_nodes_123456.json b/test/fixtures/lke_clusters_18881_nodes_123456.json index 311ef3878..646b62f5d 100644 --- a/test/fixtures/lke_clusters_18881_nodes_123456.json +++ b/test/fixtures/lke_clusters_18881_nodes_123456.json @@ -1,5 +1,5 @@ { "id": "123456", - "instance_id": 123458, + "instance_id": 456, "status": "ready" } \ No newline at end of file diff --git a/test/fixtures/lke_clusters_18881_pools_456.json b/test/fixtures/lke_clusters_18881_pools_456.json index ec6b570ac..225023d5d 100644 --- a/test/fixtures/lke_clusters_18881_pools_456.json +++ b/test/fixtures/lke_clusters_18881_pools_456.json @@ -23,5 +23,6 @@ "example tag", "another example" ], - "type": "g6-standard-4" + "type": "g6-standard-4", + "disk_encryption": "enabled" } \ No newline at end of file diff --git a/test/integration/helpers.py b/test/integration/helpers.py index 5e9d1c441..e0aab06c4 100644 --- a/test/integration/helpers.py +++ b/test/integration/helpers.py @@ -79,12 +79,14 @@ def wait_for_condition( # Retry function to help in case of requests sending too quickly before instance is ready -def retry_sending_request(retries: int, condition: Callable, *args) -> object: +def retry_sending_request( + retries: int, condition: Callable, *args, **kwargs +) -> object: curr_t = 0 while curr_t < retries: try: curr_t += 1 - res = condition(*args) + res = condition(*args, **kwargs) return res except ApiError: if curr_t >= retries: diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index 02b6220a3..01f3aaa16 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -1,4 +1,5 @@ import time +from test.integration.conftest import get_region from test.integration.helpers import ( get_test_label, retry_sending_request, @@ -18,7 +19,7 @@ Instance, Type, ) -from linode_api4.objects.linode import MigrationType +from linode_api4.objects.linode import InstanceDiskEncryptionType, MigrationType @pytest.fixture(scope="session") @@ -142,6 +143,30 @@ def create_linode_for_long_running_tests(test_linode_client, e2e_test_firewall): linode_instance.delete() +@pytest.fixture(scope="function") +def linode_with_disk_encryption(test_linode_client, request): + client = test_linode_client + + target_region = get_region(client, {"Disk Encryption"}) + timestamp = str(time.time_ns()) + label = "TestSDK-" + timestamp + + disk_encryption = request.param + + linode_instance, password = client.linode.instance_create( + "g6-nanode-1", + target_region, + image="linode/ubuntu23.04", + label=label, + booted=False, + disk_encryption=disk_encryption, + ) + + yield linode_instance + + linode_instance.delete() + + # Test helper def get_status(linode: Instance, status: str): return linode.status == status @@ -170,8 +195,7 @@ def test_linode_transfer(test_linode_client, linode_with_volume_firewall): def test_linode_rebuild(test_linode_client): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] + chosen_region = get_region(client, {"Disk Encryption"}) label = get_test_label() + "_rebuild" linode, password = client.linode.instance_create( @@ -180,12 +204,18 @@ def test_linode_rebuild(test_linode_client): wait_for_condition(10, 100, get_status, linode, "running") - retry_sending_request(3, linode.rebuild, "linode/debian10") + retry_sending_request( + 3, + linode.rebuild, + "linode/debian10", + disk_encryption=InstanceDiskEncryptionType.disabled, + ) wait_for_condition(10, 100, get_status, linode, "rebuilding") assert linode.status == "rebuilding" assert linode.image.id == "linode/debian10" + assert linode.disk_encryption == InstanceDiskEncryptionType.disabled wait_for_condition(10, 300, get_status, linode, "running") @@ -388,6 +418,18 @@ def test_linode_volumes(linode_with_volume_firewall): assert "test" in volumes[0].label +@pytest.mark.parametrize( + "linode_with_disk_encryption", ["disabled"], indirect=True +) +def test_linode_with_disk_encryption_disabled(linode_with_disk_encryption): + linode = linode_with_disk_encryption + + assert linode.disk_encryption == InstanceDiskEncryptionType.disabled + assert ( + linode.disks[0].disk_encryption == InstanceDiskEncryptionType.disabled + ) + + def wait_for_disk_status(disk: Disk, timeout): start_time = time.time() while True: diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index 2e74c8205..ce6700b80 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -1,14 +1,17 @@ import base64 import re +from test.integration.conftest import get_region from test.integration.helpers import ( get_test_label, send_request_when_resource_available, wait_for_condition, ) +from typing import Any, Dict import pytest from linode_api4 import ( + InstanceDiskEncryptionType, LKEClusterControlPlaneACLAddressesOptions, LKEClusterControlPlaneACLOptions, LKEClusterControlPlaneOptions, @@ -21,7 +24,7 @@ def lke_cluster(test_linode_client): node_type = test_linode_client.linode.types()[1] # g6-standard-1 version = test_linode_client.lke.versions()[0] - region = test_linode_client.regions().first() + region = get_region(test_linode_client, {"Disk Encryption", "Kubernetes"}) node_pools = test_linode_client.lke.node_pool(node_type, 3) label = get_test_label() + "_cluster" @@ -38,7 +41,7 @@ def lke_cluster(test_linode_client): def lke_cluster_with_acl(test_linode_client): node_type = test_linode_client.linode.types()[1] # g6-standard-1 version = test_linode_client.lke.versions()[0] - region = test_linode_client.regions().first() + region = get_region(test_linode_client, {"Kubernetes"}) node_pools = test_linode_client.lke.node_pool(node_type, 1) label = get_test_label() + "_cluster" @@ -81,9 +84,21 @@ def test_get_lke_clusters(test_linode_client, lke_cluster): def test_get_lke_pool(test_linode_client, lke_cluster): cluster = lke_cluster + wait_for_condition( + 10, + 500, + get_node_status, + cluster, + "ready", + ) + pool = test_linode_client.load(LKENodePool, cluster.pools[0].id, cluster.id) - assert cluster.pools[0].id == pool.id + def _to_comparable(p: LKENodePool) -> Dict[str, Any]: + return {k: v for k, v in p._raw_json.items() if k not in {"nodes"}} + + assert _to_comparable(cluster.pools[0]) == _to_comparable(pool) + assert pool.disk_encryption == InstanceDiskEncryptionType.enabled def test_cluster_dashboard_url_view(lke_cluster): diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index 029392ab0..700e5d0db 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -1,7 +1,11 @@ from datetime import datetime from test.unit.base import ClientBaseCase -from linode_api4 import InstancePlacementGroupAssignment, NetworkInterface +from linode_api4 import ( + InstanceDiskEncryptionType, + InstancePlacementGroupAssignment, + NetworkInterface, +) from linode_api4.objects import ( Config, ConfigInterface, @@ -36,6 +40,10 @@ def test_get_linode(self): linode.host_uuid, "3a3ddd59d9a78bb8de041391075df44de62bfec8" ) self.assertEqual(linode.watchdog_enabled, True) + self.assertEqual( + linode.disk_encryption, InstanceDiskEncryptionType.disabled + ) + self.assertEqual(linode.lke_cluster_id, None) json = linode._raw_json self.assertIsNotNone(json) @@ -72,7 +80,10 @@ def test_rebuild(self): linode = Instance(self.client, 123) with self.mock_post("/linode/instances/123") as m: - pw = linode.rebuild("linode/debian9") + pw = linode.rebuild( + "linode/debian9", + disk_encryption=InstanceDiskEncryptionType.enabled, + ) self.assertIsNotNone(pw) self.assertTrue(isinstance(pw, str)) @@ -84,6 +95,7 @@ def test_rebuild(self): { "image": "linode/debian9", "root_pass": pw, + "disk_encryption": "enabled", }, ) @@ -306,6 +318,15 @@ def test_transfer_year_month(self): m.call_url, "/linode/instances/123/transfer/2023/4" ) + def test_lke_cluster(self): + """ + Tests that you can grab the parent LKE cluster from an instance node + """ + linode = Instance(self.client, 456) + + assert linode.lke_cluster_id == 18881 + assert linode.lke_cluster.id == linode.lke_cluster_id + def test_duplicate(self): """ Tests that you can submit a correct disk clone api request @@ -318,6 +339,8 @@ def test_duplicate(self): m.call_url, "/linode/instances/123/disks/12345/clone" ) + assert disk.disk_encryption == InstanceDiskEncryptionType.disabled + def test_disk_password(self): """ Tests that you can submit a correct disk password reset api request @@ -393,7 +416,6 @@ def test_create_disk(self): image="linode/debian10", ) self.assertEqual(m.call_url, "/linode/instances/123/disks") - print(m.call_data) self.assertEqual( m.call_data, { @@ -407,6 +429,7 @@ def test_create_disk(self): ) assert disk.id == 12345 + assert disk.disk_encryption == InstanceDiskEncryptionType.disabled def test_instance_create_with_user_data(self): """ diff --git a/test/unit/objects/lke_test.py b/test/unit/objects/lke_test.py index a44db97ef..f39fb84ae 100644 --- a/test/unit/objects/lke_test.py +++ b/test/unit/objects/lke_test.py @@ -1,6 +1,7 @@ from datetime import datetime from test.unit.base import ClientBaseCase +from linode_api4 import InstanceDiskEncryptionType from linode_api4.objects import ( LKECluster, LKEClusterControlPlaneACLAddressesOptions, @@ -47,6 +48,9 @@ def test_get_pool(self): self.assertEqual(pool.id, 456) self.assertEqual(pool.cluster_id, 18881) self.assertEqual(pool.type.id, "g6-standard-4") + self.assertEqual( + pool.disk_encryption, InstanceDiskEncryptionType.enabled + ) self.assertIsNotNone(pool.disks) self.assertIsNotNone(pool.nodes) self.assertIsNotNone(pool.autoscaler) @@ -84,7 +88,7 @@ def test_node_view(self): self.assertEqual(m.call_url, "/lke/clusters/18881/nodes/123456") self.assertIsNotNone(node) self.assertEqual(node.id, "123456") - self.assertEqual(node.instance_id, 123458) + self.assertEqual(node.instance_id, 456) self.assertEqual(node.status, "ready") def test_node_delete(self):