From e1d8881271ddfd6d633525ab79761e75ba84c3de Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Tue, 11 Mar 2025 09:43:50 -0400 Subject: [PATCH 1/4] Support listing Object Storage Types (#518) * Support listing Object Storage Types * Addressed PR comments --- linode_api4/groups/object_storage.py | 19 +++++++++++++++ linode_api4/objects/object_storage.py | 19 +++++++++++++++ test/fixtures/object-storage_types.json | 23 +++++++++++++++++++ .../models/object_storage/test_obj.py | 21 +++++++++++++++++ test/unit/linode_client_test.py | 15 ++++++++++++ 5 files changed, 97 insertions(+) create mode 100644 test/fixtures/object-storage_types.json diff --git a/linode_api4/groups/object_storage.py b/linode_api4/groups/object_storage.py index f13237c58..eb6a296b7 100644 --- a/linode_api4/groups/object_storage.py +++ b/linode_api4/groups/object_storage.py @@ -8,6 +8,7 @@ from linode_api4 import ( ObjectStorageEndpoint, ObjectStorageEndpointType, + ObjectStorageType, PaginatedList, ) from linode_api4.errors import UnexpectedResponseError @@ -70,6 +71,24 @@ def keys(self, *filters): """ return self.client._get_and_filter(ObjectStorageKeys, *filters) + def types(self, *filters): + """ + Returns a paginated list of Object Storage Types. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-types + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A Paginated List of Object Storage types that match the query. + :rtype: PaginatedList of ObjectStorageType + """ + + return self.client._get_and_filter( + ObjectStorageType, *filters, endpoint="/object-storage/types" + ) + def keys_create( self, label: str, diff --git a/linode_api4/objects/object_storage.py b/linode_api4/objects/object_storage.py index 76a3945e2..be1fd0cc7 100644 --- a/linode_api4/objects/object_storage.py +++ b/linode_api4/objects/object_storage.py @@ -4,6 +4,7 @@ from deprecated import deprecated +from linode_api4.common import Price, RegionPrice from linode_api4.errors import UnexpectedResponseError from linode_api4.objects import ( Base, @@ -50,6 +51,24 @@ class ObjectStorageEndpoint(JSONObject): s3_endpoint: Optional[str] = None +class ObjectStorageType(Base): + """ + An ObjectStorageType represents the structure of a valid Object Storage type. + Currently, the ObjectStorageType can only be retrieved by listing, i.e.: + types = client.object_storage.types() + + API documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-types + """ + + properties = { + "id": Property(identifier=True), + "label": Property(), + "price": Property(json_object=Price), + "region_prices": Property(json_object=RegionPrice), + "transfer": Property(), + } + + class ObjectStorageBucket(DerivedBase): """ A bucket where objects are stored in. diff --git a/test/fixtures/object-storage_types.json b/test/fixtures/object-storage_types.json new file mode 100644 index 000000000..029823580 --- /dev/null +++ b/test/fixtures/object-storage_types.json @@ -0,0 +1,23 @@ +{ + "data": [ + { + "id": "objectstorage", + "label": "Object Storage", + "price": { + "hourly": 0.0015, + "monthly": 0.1 + }, + "region_prices": [ + { + "hourly": 0.00018, + "id": "us-east", + "monthly": 0.12 + } + ], + "transfer": 0 + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/integration/models/object_storage/test_obj.py b/test/integration/models/object_storage/test_obj.py index 0f3e39f33..33ce8dfbe 100644 --- a/test/integration/models/object_storage/test_obj.py +++ b/test/integration/models/object_storage/test_obj.py @@ -3,6 +3,7 @@ import pytest +from linode_api4.common import RegionPrice from linode_api4.linode_client import LinodeClient from linode_api4.objects.object_storage import ( ObjectStorageACL, @@ -11,6 +12,7 @@ ObjectStorageEndpointType, ObjectStorageKeyPermission, ObjectStorageKeys, + ObjectStorageType, ) @@ -191,3 +193,22 @@ def test_get_buckets_in_cluster( ): cluster = test_linode_client.load(ObjectStorageCluster, bucket.cluster) assert any(bucket.id == b.id for b in cluster.buckets_in_cluster()) + + +def test_object_storage_types(test_linode_client): + types = test_linode_client.object_storage.types() + + if len(types) > 0: + for object_storage_type in types: + assert type(object_storage_type) is ObjectStorageType + assert object_storage_type.price.monthly is None or ( + isinstance(object_storage_type.price.monthly, (float, int)) + and object_storage_type.price.monthly >= 0 + ) + if len(object_storage_type.region_prices) > 0: + region_price = object_storage_type.region_prices[0] + assert type(region_price) is RegionPrice + assert object_storage_type.price.monthly is None or ( + isinstance(object_storage_type.price.monthly, (float, int)) + and object_storage_type.price.monthly >= 0 + ) diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index 41cb9100d..c79c0a88d 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -980,6 +980,21 @@ def test_get_keys(self): self.assertEqual(key2.access_key, "testAccessKeyHere456") self.assertEqual(key2.secret_key, "[REDACTED]") + def test_object_storage_types(self): + """ + Tests that a list of ObjectStorageTypes can be retrieved + """ + types = self.client.object_storage.types() + self.assertEqual(len(types), 1) + self.assertEqual(types[0].id, "objectstorage") + self.assertEqual(types[0].label, "Object Storage") + self.assertEqual(types[0].price.hourly, 0.0015) + self.assertEqual(types[0].price.monthly, 0.1) + self.assertEqual(types[0].region_prices[0].id, "us-east") + self.assertEqual(types[0].region_prices[0].hourly, 0.00018) + self.assertEqual(types[0].region_prices[0].monthly, 0.12) + self.assertEqual(types[0].transfer, 0) + def test_keys_create(self): """ Tests that you can create Object Storage Keys From 70169d75964549d134d417b57b83706d8bb86682 Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Tue, 11 Mar 2025 10:23:43 -0400 Subject: [PATCH 2/4] Added support for firewall rule version endpoints (#520) * Added support for firewall rule version endpoints * Updated get_rule_versions to be a property method --- linode_api4/objects/networking.py | 31 +++++++++++ .../networking_firewalls_123_history.json | 21 ++++++++ ...working_firewalls_123_history_rules_2.json | 24 +++++++++ .../models/networking/test_networking.py | 54 +++++++++++++++++++ test/unit/objects/networking_test.py | 48 +++++++++++++++++ 5 files changed, 178 insertions(+) create mode 100644 test/fixtures/networking_firewalls_123_history.json create mode 100644 test/fixtures/networking_firewalls_123_history_rules_2.json diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index 25130a919..b7a16ae90 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -244,6 +244,37 @@ def get_rules(self): "{}/rules".format(self.api_endpoint), model=self ) + @property + def rule_versions(self): + """ + Gets the JSON rule versions for this Firewall. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-firewall-rule-versions + + :returns: Lists the current and historical rules of the firewall (that is not deleted), + using version. Whenever the rules update, the version increments from 1. + :rtype: dict + """ + return self._client.get( + "{}/history".format(self.api_endpoint), model=self + ) + + def get_rule_version(self, version): + """ + Gets the JSON for a specific rule version for this Firewall. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-firewall-rule-version + + :param version: The firewall rule version to view. + :type version: int + + :returns: Gets a specific firewall rule version for an enabled or disabled firewall. + :rtype: dict + """ + return self._client.get( + "{}/history/rules/{}".format(self.api_endpoint, version), model=self + ) + def device_create(self, id, type="linode", **kwargs): """ Creates and attaches a device to this Firewall diff --git a/test/fixtures/networking_firewalls_123_history.json b/test/fixtures/networking_firewalls_123_history.json new file mode 100644 index 000000000..13f2b0df7 --- /dev/null +++ b/test/fixtures/networking_firewalls_123_history.json @@ -0,0 +1,21 @@ +{ + "data": [ + { + "updated": "2025-03-07T17:06:36", + "status": "enabled", + "rules": { + "version": 1 + } + }, + { + "updated": "2025-03-07T17:06:36", + "status": "enabled", + "rules": { + "version": 2 + } + } + ], + "page": 1, + "pages": 1, + "results": 2 +} diff --git a/test/fixtures/networking_firewalls_123_history_rules_2.json b/test/fixtures/networking_firewalls_123_history_rules_2.json new file mode 100644 index 000000000..3819436f8 --- /dev/null +++ b/test/fixtures/networking_firewalls_123_history_rules_2.json @@ -0,0 +1,24 @@ +{ + "inbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "0.0.0.0/0" + ], + "ipv6": [ + "ff00::/8" + ] + }, + "description": "A really cool firewall rule.", + "label": "really-cool-firewall-rule", + "ports": "80", + "protocol": "TCP" + } + ], + "inbound_policy": "ACCEPT", + "outbound": [], + "outbound_policy": "DROP", + "version": 2, + "fingerprint": "96c9568c" +} diff --git a/test/integration/models/networking/test_networking.py b/test/integration/models/networking/test_networking.py index 032436246..b92cdfadc 100644 --- a/test/integration/models/networking/test_networking.py +++ b/test/integration/models/networking/test_networking.py @@ -1,3 +1,4 @@ +import time from test.integration.conftest import ( get_api_ca_file, get_api_url, @@ -72,6 +73,59 @@ def test_get_networking_rules(test_linode_client, test_firewall): assert "outbound_policy" in str(rules) +def test_get_networking_rule_versions(test_linode_client, test_firewall): + firewall = test_linode_client.load(Firewall, test_firewall.id) + + # Update the firewall's rules + new_rules = { + "inbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": ["0.0.0.0/0"], + "ipv6": ["ff00::/8"], + }, + "description": "A really cool firewall rule.", + "label": "really-cool-firewall-rule", + "ports": "80", + "protocol": "TCP", + } + ], + "inbound_policy": "ACCEPT", + "outbound": [], + "outbound_policy": "DROP", + } + firewall.update_rules(new_rules) + time.sleep(1) + + rule_versions = firewall.rule_versions + + # Original firewall rules + old_rule_version = firewall.get_rule_version(1) + + # Updated firewall rules + new_rule_version = firewall.get_rule_version(2) + + assert "rules" in str(rule_versions) + assert "version" in str(rule_versions) + assert rule_versions["results"] == 2 + + assert old_rule_version["inbound"] == [] + assert old_rule_version["inbound_policy"] == "ACCEPT" + assert old_rule_version["outbound"] == [] + assert old_rule_version["outbound_policy"] == "DROP" + assert old_rule_version["version"] == 1 + + assert ( + new_rule_version["inbound"][0]["description"] + == "A really cool firewall rule." + ) + assert new_rule_version["inbound_policy"] == "ACCEPT" + assert new_rule_version["outbound"] == [] + assert new_rule_version["outbound_policy"] == "DROP" + assert new_rule_version["version"] == 2 + + @pytest.mark.smoke def test_ip_addresses_share( test_linode_client, diff --git a/test/unit/objects/networking_test.py b/test/unit/objects/networking_test.py index d12167d8c..f982dd6f7 100644 --- a/test/unit/objects/networking_test.py +++ b/test/unit/objects/networking_test.py @@ -47,6 +47,54 @@ def test_get_rules(self): self.assertEqual(result["inbound_policy"], "DROP") self.assertEqual(result["outbound_policy"], "DROP") + def test_get_rule_versions(self): + """ + Tests that you can submit a correct firewall rule versions view api request. + """ + + firewall = Firewall(self.client, 123) + + with self.mock_get("/networking/firewalls/123/history") as m: + result = firewall.rule_versions + self.assertEqual(m.call_url, "/networking/firewalls/123/history") + self.assertEqual(result["data"][0]["status"], "enabled") + self.assertEqual(result["data"][0]["rules"]["version"], 1) + self.assertEqual(result["data"][0]["status"], "enabled") + self.assertEqual(result["data"][1]["rules"]["version"], 2) + + def test_get_rule_version(self): + """ + Tests that you can submit a correct firewall rule version view api request. + """ + + firewall = Firewall(self.client, 123) + + with self.mock_get("/networking/firewalls/123/history/rules/2") as m: + result = firewall.get_rule_version(2) + self.assertEqual( + m.call_url, "/networking/firewalls/123/history/rules/2" + ) + self.assertEqual(result["inbound"][0]["action"], "ACCEPT") + self.assertEqual( + result["inbound"][0]["addresses"]["ipv4"][0], "0.0.0.0/0" + ) + self.assertEqual( + result["inbound"][0]["addresses"]["ipv6"][0], "ff00::/8" + ) + self.assertEqual( + result["inbound"][0]["description"], + "A really cool firewall rule.", + ) + self.assertEqual( + result["inbound"][0]["label"], "really-cool-firewall-rule" + ) + self.assertEqual(result["inbound"][0]["ports"], "80") + self.assertEqual(result["inbound"][0]["protocol"], "TCP") + self.assertEqual(result["outbound"], []) + self.assertEqual(result["inbound_policy"], "ACCEPT") + self.assertEqual(result["outbound_policy"], "DROP") + self.assertEqual(result["version"], 2) + def test_rdns_reset(self): """ Tests that the RDNS of an IP and be reset using an explicit null value. From 3692712d7a1b601a49f1f106330582acfca0e36a Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Thu, 20 Mar 2025 14:14:53 -0400 Subject: [PATCH 3/4] Add support to suspend and resume database (#519) * suspend and resume db * address comment --- linode_api4/objects/database.py | 48 +++++++++++++ .../databases_mysql_instances_123_resume.json | 1 + ...databases_mysql_instances_123_suspend.json | 1 + ...bases_postgresql_instances_123_resume.json | 1 + ...ases_postgresql_instances_123_suspend.json | 1 + .../models/database/test_database.py | 68 +++++++++++++++++++ test/unit/objects/database_test.py | 56 +++++++++++++++ 7 files changed, 176 insertions(+) create mode 100644 test/fixtures/databases_mysql_instances_123_resume.json create mode 100644 test/fixtures/databases_mysql_instances_123_suspend.json create mode 100644 test/fixtures/databases_postgresql_instances_123_resume.json create mode 100644 test/fixtures/databases_postgresql_instances_123_suspend.json diff --git a/linode_api4/objects/database.py b/linode_api4/objects/database.py index 58044edb0..dc9db8471 100644 --- a/linode_api4/objects/database.py +++ b/linode_api4/objects/database.py @@ -265,6 +265,30 @@ def invalidate(self): Base.invalidate(self) + def suspend(self): + """ + Suspend a MySQL Managed Database, releasing idle resources and keeping only necessary data. + + API documentation: https://techdocs.akamai.com/linode-api/reference/suspend-databases-mysql-instance + """ + self._client.post( + "{}/suspend".format(MySQLDatabase.api_endpoint), model=self + ) + + return self.invalidate() + + def resume(self): + """ + Resume a suspended MySQL Managed Database. + + API documentation: https://techdocs.akamai.com/linode-api/reference/resume-databases-mysql-instance + """ + self._client.post( + "{}/resume".format(MySQLDatabase.api_endpoint), model=self + ) + + return self.invalidate() + class PostgreSQLDatabase(Base): """ @@ -405,6 +429,30 @@ def invalidate(self): Base.invalidate(self) + def suspend(self): + """ + Suspend a PostgreSQL Managed Database, releasing idle resources and keeping only necessary data. + + API documentation: https://techdocs.akamai.com/linode-api/reference/suspend-databases-postgre-sql-instance + """ + self._client.post( + "{}/suspend".format(PostgreSQLDatabase.api_endpoint), model=self + ) + + return self.invalidate() + + def resume(self): + """ + Resume a suspended PostgreSQL Managed Database. + + API documentation: https://techdocs.akamai.com/linode-api/reference/resume-databases-postgre-sql-instance + """ + self._client.post( + "{}/resume".format(PostgreSQLDatabase.api_endpoint), model=self + ) + + return self.invalidate() + ENGINE_TYPE_TRANSLATION = { "mysql": MySQLDatabase, diff --git a/test/fixtures/databases_mysql_instances_123_resume.json b/test/fixtures/databases_mysql_instances_123_resume.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/test/fixtures/databases_mysql_instances_123_resume.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/test/fixtures/databases_mysql_instances_123_suspend.json b/test/fixtures/databases_mysql_instances_123_suspend.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/test/fixtures/databases_mysql_instances_123_suspend.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/test/fixtures/databases_postgresql_instances_123_resume.json b/test/fixtures/databases_postgresql_instances_123_resume.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/test/fixtures/databases_postgresql_instances_123_resume.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/test/fixtures/databases_postgresql_instances_123_suspend.json b/test/fixtures/databases_postgresql_instances_123_suspend.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/test/fixtures/databases_postgresql_instances_123_suspend.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/test/integration/models/database/test_database.py b/test/integration/models/database/test_database.py index 1ad5dde3b..351c09c2a 100644 --- a/test/integration/models/database/test_database.py +++ b/test/integration/models/database/test_database.py @@ -165,6 +165,40 @@ def test_database_instance(test_linode_client, test_create_sql_db): assert str(test_create_sql_db.id) in str(dbs.lists) +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_mysql_suspend_resume(test_linode_client, test_create_sql_db): + db = test_linode_client.load(MySQLDatabase, test_create_sql_db.id) + + db.suspend() + + wait_for_condition( + 10, + 300, + get_sql_db_status, + test_linode_client, + test_create_sql_db.id, + "suspended", + ) + + assert db.status == "suspended" + + db.resume() + + wait_for_condition( + 30, + 600, + get_sql_db_status, + test_linode_client, + test_create_sql_db.id, + "active", + ) + + assert db.status == "active" + + # ------- POSTGRESQL DB Test cases ------- @pytest.mark.skipif( os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, @@ -411,3 +445,37 @@ def test_reset_postgres_credentials( assert db.credentials.username == "akmadmin" assert db.credentials.password != old_pass + + +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_postgres_suspend_resume(test_linode_client, test_create_postgres_db): + db = test_linode_client.load(PostgreSQLDatabase, test_create_postgres_db.id) + + db.suspend() + + wait_for_condition( + 10, + 300, + get_postgres_db_status, + test_linode_client, + test_create_postgres_db.id, + "suspended", + ) + + assert db.status == "suspended" + + db.resume() + + wait_for_condition( + 30, + 600, + get_postgres_db_status, + test_linode_client, + test_create_postgres_db.id, + "active", + ) + + assert db.status == "active" diff --git a/test/unit/objects/database_test.py b/test/unit/objects/database_test.py index 11b2379aa..51c7de4cd 100644 --- a/test/unit/objects/database_test.py +++ b/test/unit/objects/database_test.py @@ -263,6 +263,34 @@ def test_reset_credentials(self): m.call_url, "/databases/mysql/instances/123/credentials/reset" ) + def test_suspend(self): + """ + Test MySQL Database suspend logic. + """ + with self.mock_post("/databases/mysql/instances/123/suspend") as m: + db = MySQLDatabase(self.client, 123) + + db.suspend() + + self.assertEqual(m.method, "post") + self.assertEqual( + m.call_url, "/databases/mysql/instances/123/suspend" + ) + + def test_resume(self): + """ + Test MySQL Database resume logic. + """ + with self.mock_post("/databases/mysql/instances/123/resume") as m: + db = MySQLDatabase(self.client, 123) + + db.resume() + + self.assertEqual(m.method, "post") + self.assertEqual( + m.call_url, "/databases/mysql/instances/123/resume" + ) + class PostgreSQLDatabaseTest(ClientBaseCase): """ @@ -451,3 +479,31 @@ def test_reset_credentials(self): m.call_url, "/databases/postgresql/instances/123/credentials/reset", ) + + def test_suspend(self): + """ + Test PostgreSQL Database suspend logic. + """ + with self.mock_post("/databases/postgresql/instances/123/suspend") as m: + db = PostgreSQLDatabase(self.client, 123) + + db.suspend() + + self.assertEqual(m.method, "post") + self.assertEqual( + m.call_url, "/databases/postgresql/instances/123/suspend" + ) + + def test_resume(self): + """ + Test PostgreSQL Database resume logic. + """ + with self.mock_post("/databases/postgresql/instances/123/resume") as m: + db = PostgreSQLDatabase(self.client, 123) + + db.resume() + + self.assertEqual(m.method, "post") + self.assertEqual( + m.call_url, "/databases/postgresql/instances/123/resume" + ) From 95970e4ac3368742ac3d5677695d4123ce69587e Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Mon, 31 Mar 2025 09:52:26 -0400 Subject: [PATCH 4/4] Implement Support for LKE Enterprise (#521) * Implement support for LKE enterprise * Correct test --- linode_api4/groups/__init__.py | 1 + linode_api4/groups/lke.py | 26 +++++- linode_api4/groups/lke_tier.py | 40 ++++++++++ linode_api4/linode_client.py | 17 +++- linode_api4/objects/lke.py | 50 +++++++++++- test/fixtures/lke_clusters_18881.json | 1 + test/fixtures/lke_clusters_18882.json | 14 ++++ .../lke_clusters_18882_pools_789.json | 18 +++++ .../fixtures/lke_tiers_standard_versions.json | 19 +++++ test/integration/models/lke/test_lke.py | 79 +++++++++++++++++++ test/unit/groups/lke_tier_test.py | 18 +++++ test/unit/objects/lke_test.py | 22 +++++- 12 files changed, 297 insertions(+), 8 deletions(-) create mode 100644 linode_api4/groups/lke_tier.py create mode 100644 test/fixtures/lke_clusters_18882.json create mode 100644 test/fixtures/lke_clusters_18882_pools_789.json create mode 100644 test/fixtures/lke_tiers_standard_versions.json create mode 100644 test/unit/groups/lke_tier_test.py diff --git a/linode_api4/groups/__init__.py b/linode_api4/groups/__init__.py index db08d8939..e50eeab66 100644 --- a/linode_api4/groups/__init__.py +++ b/linode_api4/groups/__init__.py @@ -8,6 +8,7 @@ from .image import * from .linode import * from .lke import * +from .lke_tier import * from .longview import * from .networking import * from .nodebalancer import * diff --git a/linode_api4/groups/lke.py b/linode_api4/groups/lke.py index 4d13bb650..c3d6fdc5d 100644 --- a/linode_api4/groups/lke.py +++ b/linode_api4/groups/lke.py @@ -1,7 +1,8 @@ -from typing import Any, Dict, Union +from typing import Any, Dict, Optional, Union from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group +from linode_api4.groups.lke_tier import LKETierGroup from linode_api4.objects import ( KubeVersion, LKECluster, @@ -67,6 +68,7 @@ def cluster_create( LKEClusterControlPlaneOptions, Dict[str, Any] ] = None, apl_enabled: bool = False, + tier: Optional[str] = None, **kwargs, ): """ @@ -104,9 +106,13 @@ def cluster_create( :param control_plane: The control plane configuration of this LKE cluster. :type control_plane: Dict[str, Any] or LKEClusterControlPlaneRequest :param apl_enabled: Whether this cluster should use APL. - NOTE: This endpoint is in beta and may only + NOTE: This field is in beta and may only function if base_url is set to `https://api.linode.com/v4beta`. :type apl_enabled: bool + :param tier: The tier of LKE cluster to create. + NOTE: This field is in beta and may only + function if base_url is set to `https://api.linode.com/v4beta`. + :type tier: str :param kwargs: Any other arguments to pass along to the API. See the API docs for possible values. @@ -122,6 +128,7 @@ def cluster_create( node_pools if isinstance(node_pools, list) else [node_pools] ), "control_plane": control_plane, + "tier": tier, } params.update(kwargs) @@ -183,3 +190,18 @@ def types(self, *filters): return self.client._get_and_filter( LKEType, *filters, endpoint="/lke/types" ) + + def tier(self, id: str) -> LKETierGroup: + """ + Returns an object representing the LKE tier API path. + + NOTE: LKE tiers may not currently be available to all users. + + :param id: The ID of the tier. + :type id: str + + :returns: An object representing the LKE tier API path. + :rtype: LKETier + """ + + return LKETierGroup(self.client, id) diff --git a/linode_api4/groups/lke_tier.py b/linode_api4/groups/lke_tier.py new file mode 100644 index 000000000..e5b8d11e5 --- /dev/null +++ b/linode_api4/groups/lke_tier.py @@ -0,0 +1,40 @@ +from linode_api4.groups import Group +from linode_api4.objects import TieredKubeVersion + + +class LKETierGroup(Group): + """ + Encapsulates methods related to a specific LKE tier. This + should not be instantiated on its own, but should instead be used through + an instance of :any:`LinodeClient`:: + + client = LinodeClient(token) + instances = client.lke.tier("standard") # use the LKETierGroup + + This group contains all features beneath the `/lke/tiers/{tier}` group in the API v4. + """ + + def __init__(self, client: "LinodeClient", tier: str): + super().__init__(client) + self.tier = tier + + def versions(self, *filters): + """ + Returns a paginated list of versions for this tier matching the given filters. + + API Documentation: Not Yet Available + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A paginated list of kube versions that match the query. + :rtype: PaginatedList of TieredKubeVersion + """ + + return self.client._get_and_filter( + TieredKubeVersion, + endpoint=f"/lke/tiers/{self.tier}/versions", + parent_id=self.tier, + *filters, + ) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index dbb45d0df..19e6f3900 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -455,7 +455,13 @@ def volume_create(self, label, region=None, linode=None, size=20, **kwargs): ) # helper functions - def _get_and_filter(self, obj_type, *filters, endpoint=None): + def _get_and_filter( + self, + obj_type, + *filters, + endpoint=None, + parent_id=None, + ): parsed_filters = None if filters: if len(filters) > 1: @@ -467,8 +473,13 @@ def _get_and_filter(self, obj_type, *filters, endpoint=None): # Use sepcified endpoint if endpoint: - return self._get_objects(endpoint, obj_type, filters=parsed_filters) + return self._get_objects( + endpoint, obj_type, parent_id=parent_id, filters=parsed_filters + ) else: return self._get_objects( - obj_type.api_list(), obj_type, filters=parsed_filters + obj_type.api_list(), + obj_type, + parent_id=parent_id, + filters=parsed_filters, ) diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index 2f670f2b9..7086b1113 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -14,6 +14,7 @@ Region, Type, ) +from linode_api4.objects.base import _flatten_request_body_recursive from linode_api4.util import drop_null_keys @@ -49,6 +50,26 @@ class KubeVersion(Base): } +class TieredKubeVersion(DerivedBase): + """ + A TieredKubeVersion is a version of Kubernetes that is specific to a certain LKE tier. + + NOTE: LKE tiers may not currently be available to all users. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-lke-version + """ + + api_endpoint = "/lke/tiers/{tier}/versions/{id}" + parent_id_name = "tier" + id_attribute = "id" + derived_url_path = "versions" + + properties = { + "id": Property(identifier=True), + "tier": Property(identifier=True), + } + + @dataclass class LKENodePoolTaint(JSONObject): """ @@ -154,6 +175,8 @@ class LKENodePool(DerivedBase): An LKE Node Pool describes a pool of Linode Instances that exist within an LKE Cluster. + NOTE: The k8s_version and update_strategy fields are only available for LKE Enterprise clusters. + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-lke-node-pool """ @@ -175,6 +198,12 @@ class LKENodePool(DerivedBase): "tags": Property(mutable=True, unordered=True), "labels": Property(mutable=True), "taints": Property(mutable=True), + # Enterprise-specific properties + # Ideally we would use slug_relationship=TieredKubeVersion here, but + # it isn't possible without an extra request because the tier is not + # directly exposed in the node pool response. + "k8s_version": Property(mutable=True), + "update_strategy": Property(mutable=True), } def _parse_raw_node( @@ -255,6 +284,7 @@ class LKECluster(Base): "pools": Property(derived_class=LKENodePool), "control_plane": Property(mutable=True), "apl_enabled": Property(), + "tier": Property(), } def invalidate(self): @@ -385,6 +415,10 @@ def node_pool_create( node_count: int, labels: Optional[Dict[str, str]] = None, taints: List[Union[LKENodePoolTaint, Dict[str, Any]]] = None, + k8s_version: Optional[ + Union[str, KubeVersion, TieredKubeVersion] + ] = None, + update_strategy: Optional[str] = None, **kwargs, ): """ @@ -399,7 +433,13 @@ def node_pool_create( :param labels: A dict mapping labels to their values to apply to this pool. :type labels: Dict[str, str] :param taints: A list of taints to apply to this pool. - :type taints: List of :any:`LKENodePoolTaint` or dict + :type taints: List of :any:`LKENodePoolTaint` or dict. + :param k8s_version: The Kubernetes version to use for this pool. + NOTE: This field is specific to enterprise clusters. + :type k8s_version: str, KubeVersion, or TieredKubeVersion + :param update_strategy: The strategy to use when updating this node pool. + NOTE: This field is specific to enterprise clusters. + :type update_strategy: str :param kwargs: Any other arguments to pass to the API. See the API docs for possible values. @@ -409,6 +449,10 @@ def node_pool_create( params = { "type": node_type, "count": node_count, + "labels": labels, + "taints": taints, + "k8s_version": k8s_version, + "update_strategy": update_strategy, } if labels is not None: @@ -420,7 +464,9 @@ def node_pool_create( params.update(kwargs) result = self._client.post( - "{}/pools".format(LKECluster.api_endpoint), model=self, data=params + "{}/pools".format(LKECluster.api_endpoint), + model=self, + data=drop_null_keys(_flatten_request_body_recursive(params)), ) self.invalidate() diff --git a/test/fixtures/lke_clusters_18881.json b/test/fixtures/lke_clusters_18881.json index bb5807c18..a520e49ea 100644 --- a/test/fixtures/lke_clusters_18881.json +++ b/test/fixtures/lke_clusters_18881.json @@ -6,6 +6,7 @@ "label": "example-cluster", "region": "ap-west", "k8s_version": "1.19", + "tier": "standard", "tags": [], "control_plane": { "high_availability": true diff --git a/test/fixtures/lke_clusters_18882.json b/test/fixtures/lke_clusters_18882.json new file mode 100644 index 000000000..49548c018 --- /dev/null +++ b/test/fixtures/lke_clusters_18882.json @@ -0,0 +1,14 @@ +{ + "id": 18881, + "status": "ready", + "created": "2021-02-10T23:54:21", + "updated": "2021-02-10T23:54:21", + "label": "example-cluster-2", + "region": "ap-west", + "k8s_version": "1.31.1+lke1", + "tier": "enterprise", + "tags": [], + "control_plane": { + "high_availability": true + } +} \ No newline at end of file diff --git a/test/fixtures/lke_clusters_18882_pools_789.json b/test/fixtures/lke_clusters_18882_pools_789.json new file mode 100644 index 000000000..a7bbc4749 --- /dev/null +++ b/test/fixtures/lke_clusters_18882_pools_789.json @@ -0,0 +1,18 @@ +{ + "id": 789, + "type": "g6-standard-2", + "count": 3, + "nodes": [], + "disks": [], + "autoscaler": { + "enabled": false, + "min": 3, + "max": 3 + }, + "labels": {}, + "taints": [], + "tags": [], + "disk_encryption": "enabled", + "k8s_version": "1.31.1+lke1", + "update_strategy": "rolling_update" +} \ No newline at end of file diff --git a/test/fixtures/lke_tiers_standard_versions.json b/test/fixtures/lke_tiers_standard_versions.json new file mode 100644 index 000000000..5dfeeb4ab --- /dev/null +++ b/test/fixtures/lke_tiers_standard_versions.json @@ -0,0 +1,19 @@ +{ + "data": [ + { + "id": "1.32", + "tier": "standard" + }, + { + "id": "1.31", + "tier": "standard" + }, + { + "id": "1.30", + "tier": "standard" + } + ], + "page": 1, + "pages": 1, + "results": 3 +} diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index 794bc3203..e4c941c16 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -14,6 +14,7 @@ LKEClusterControlPlaneACLAddressesOptions, LKEClusterControlPlaneACLOptions, LKEClusterControlPlaneOptions, + TieredKubeVersion, ) from linode_api4.common import RegionPrice from linode_api4.errors import ApiError @@ -136,6 +137,38 @@ def lke_cluster_with_apl(test_linode_client): cluster.delete() +@pytest.fixture(scope="session") +def lke_cluster_enterprise(test_linode_client): + # We use the oldest version here so we can test upgrades + version = sorted( + v.id for v in test_linode_client.lke.tier("enterprise").versions() + )[0] + + region = get_region( + test_linode_client, {"Kubernetes Enterprise", "Disk Encryption"} + ) + + node_pools = test_linode_client.lke.node_pool( + "g6-dedicated-2", + 3, + k8s_version=version, + update_strategy="rolling_update", + ) + label = get_test_label() + "_cluster" + + cluster = test_linode_client.lke.cluster_create( + region, + label, + node_pools, + version, + tier="enterprise", + ) + + yield cluster + + cluster.delete() + + def get_cluster_status(cluster: LKECluster, status: str): return cluster._raw_json["status"] == status @@ -398,6 +431,52 @@ def test_lke_cluster_with_apl(lke_cluster_with_apl): ) +def test_lke_cluster_enterprise(test_linode_client, lke_cluster_enterprise): + lke_cluster_enterprise.invalidate() + assert lke_cluster_enterprise.tier == "enterprise" + + pool = lke_cluster_enterprise.pools[0] + assert str(pool.k8s_version) == lke_cluster_enterprise.k8s_version.id + assert pool.update_strategy == "rolling_update" + + target_version = sorted( + v.id for v in test_linode_client.lke.tier("enterprise").versions() + )[0] + pool.update_strategy = "on_recycle" + pool.k8s_version = target_version + + pool.save() + + pool.invalidate() + + assert pool.k8s_version == target_version + assert pool.update_strategy == "on_recycle" + + +def test_lke_tiered_versions(test_linode_client): + def __assert_version(tier: str, version: TieredKubeVersion): + assert version.tier == tier + assert len(version.id) > 0 + + standard_versions = test_linode_client.lke.tier("standard").versions() + assert len(standard_versions) > 0 + + standard_version = standard_versions[0] + __assert_version("standard", standard_version) + + standard_version.invalidate() + __assert_version("standard", standard_version) + + enterprise_versions = test_linode_client.lke.tier("enterprise").versions() + assert len(enterprise_versions) > 0 + + enterprise_version = enterprise_versions[0] + __assert_version("enterprise", enterprise_version) + + enterprise_version.invalidate() + __assert_version("enterprise", enterprise_version) + + def test_lke_types(test_linode_client): types = test_linode_client.lke.types() diff --git a/test/unit/groups/lke_tier_test.py b/test/unit/groups/lke_tier_test.py new file mode 100644 index 000000000..de4ae5212 --- /dev/null +++ b/test/unit/groups/lke_tier_test.py @@ -0,0 +1,18 @@ +from test.unit.base import ClientBaseCase + + +class LKETierGroupTest(ClientBaseCase): + """ + Tests methods under the LKETierGroup class. + """ + + def test_list_versions(self): + """ + Tests that LKE versions can be listed for a given tier. + """ + + tiers = self.client.lke.tier("standard").versions() + + assert tiers[0].id == "1.32" + assert tiers[1].id == "1.31" + assert tiers[2].id == "1.30" diff --git a/test/unit/objects/lke_test.py b/test/unit/objects/lke_test.py index 1f397afac..a0ad63288 100644 --- a/test/unit/objects/lke_test.py +++ b/test/unit/objects/lke_test.py @@ -2,7 +2,7 @@ from test.unit.base import ClientBaseCase from unittest.mock import MagicMock -from linode_api4 import InstanceDiskEncryptionType +from linode_api4 import InstanceDiskEncryptionType, TieredKubeVersion from linode_api4.objects import ( LKECluster, LKEClusterControlPlaneACLAddressesOptions, @@ -536,3 +536,23 @@ def test_cluster_update_acl_null_addresses(self): # Addresses should not be included in the API request if it's null # See: TPT-3489 assert m.call_data == {"acl": {"enabled": True}} + + def test_cluster_enterprise(self): + cluster = LKECluster(self.client, 18882) + + assert cluster.tier == "enterprise" + assert cluster.k8s_version.id == "1.31.1+lke1" + + pool = LKENodePool(self.client, 789, 18882) + assert pool.k8s_version == "1.31.1+lke1" + assert pool.update_strategy == "rolling_update" + + def test_lke_tiered_version(self): + version = TieredKubeVersion(self.client, "1.32", "standard") + + assert version.id == "1.32" + + # Ensure the version is properly refreshed + version.invalidate() + + assert version.id == "1.32"