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"