From 1d155a98fcf20f69114117609f3f116affc4b3e1 Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Wed, 11 Sep 2024 14:29:46 -0400 Subject: [PATCH] new: Support pricing for LKE, Volume, NodeBalancer and Network Transfer (#454) * support pricing * Add more detailed assertions for integration tests --------- Co-authored-by: ykim-1 --- linode_api4/common.py | 24 +++++++ linode_api4/groups/lke.py | 19 ++++++ linode_api4/groups/networking.py | 19 ++++++ linode_api4/groups/nodebalancer.py | 20 +++++- linode_api4/groups/volume.py | 20 +++++- linode_api4/objects/__init__.py | 2 +- linode_api4/objects/lke.py | 19 ++++++ linode_api4/objects/networking.py | 19 ++++++ linode_api4/objects/nodebalancer.py | 19 ++++++ linode_api4/objects/volume.py | 19 ++++++ test/fixtures/lke_types.json | 38 +++++++++++ test/fixtures/network-transfer_prices.json | 38 +++++++++++ test/fixtures/nodebalancers_types.json | 28 ++++++++ test/fixtures/volumes_types.json | 28 ++++++++ test/integration/models/lke/test_lke.py | 27 +++++++- .../models/networking/test_networking.py | 13 ++++ .../models/nodebalancer/test_nodebalancer.py | 26 ++++++- test/integration/models/volume/test_volume.py | 21 +++++- test/unit/linode_client_test.py | 67 +++++++++++++++++++ 19 files changed, 460 insertions(+), 6 deletions(-) create mode 100644 test/fixtures/lke_types.json create mode 100644 test/fixtures/network-transfer_prices.json create mode 100644 test/fixtures/nodebalancers_types.json create mode 100644 test/fixtures/volumes_types.json diff --git a/linode_api4/common.py b/linode_api4/common.py index df3da9733..7e98b1977 100644 --- a/linode_api4/common.py +++ b/linode_api4/common.py @@ -1,4 +1,7 @@ import os +from dataclasses import dataclass + +from linode_api4.objects import JSONObject SSH_KEY_TYPES = ( "ssh-dss", @@ -57,3 +60,24 @@ def load_and_validate_keys(authorized_keys): ) ) return ret + + +@dataclass +class Price(JSONObject): + """ + Price contains the core fields of a price object returned by various pricing endpoints. + """ + + hourly: int = 0 + monthly: int = 0 + + +@dataclass +class RegionPrice(JSONObject): + """ + RegionPrice contains the core fields of a region_price object returned by various pricing endpoints. + """ + + id: int = 0 + hourly: int = 0 + monthly: int = 0 diff --git a/linode_api4/groups/lke.py b/linode_api4/groups/lke.py index f2bc5a388..b60090595 100644 --- a/linode_api4/groups/lke.py +++ b/linode_api4/groups/lke.py @@ -6,6 +6,7 @@ KubeVersion, LKECluster, LKEClusterControlPlaneOptions, + LKEType, Type, drop_null_keys, ) @@ -155,3 +156,21 @@ def node_pool(self, node_type: Union[Type, str], node_count: int, **kwargs): result.update(kwargs) return result + + def types(self, *filters): + """ + Returns a :any:`PaginatedList` of :any:`LKEType` objects that represents a valid LKE type. + + API Documentation: TODO + + :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 LKE types that match the query. + :rtype: PaginatedList of LKEType + """ + + return self.client._get_and_filter( + LKEType, *filters, endpoint="/lke/types" + ) diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py index 7ba6919e4..5d49e9bb3 100644 --- a/linode_api4/groups/networking.py +++ b/linode_api4/groups/networking.py @@ -8,6 +8,7 @@ IPAddress, IPv6Pool, IPv6Range, + NetworkTransferPrice, Region, ) @@ -348,3 +349,21 @@ def ip_addresses_assign(self, assignments, region): params = {"assignments": assignments, "region": region} self.client.post("/networking/ips/assign", model=self, data=params) + + def transfer_prices(self, *filters): + """ + Returns a :any:`PaginatedList` of :any:`NetworkTransferPrice` objects that represents a valid network transfer price. + + API Documentation: TODO + + :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 network transfer price that match the query. + :rtype: PaginatedList of NetworkTransferPrice + """ + + return self.client._get_and_filter( + NetworkTransferPrice, *filters, endpoint="/network-transfer/prices" + ) diff --git a/linode_api4/groups/nodebalancer.py b/linode_api4/groups/nodebalancer.py index 50068f8eb..acc1f07e2 100644 --- a/linode_api4/groups/nodebalancer.py +++ b/linode_api4/groups/nodebalancer.py @@ -1,6 +1,6 @@ from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group -from linode_api4.objects import Base, NodeBalancer +from linode_api4.objects import Base, NodeBalancer, NodeBalancerType class NodeBalancerGroup(Group): @@ -50,3 +50,21 @@ def create(self, region, **kwargs): n = NodeBalancer(self.client, result["id"], result) return n + + def types(self, *filters): + """ + Returns a :any:`PaginatedList` of :any:`NodeBalancerType` objects that represents a valid NodeBalancer type. + + API Documentation: TODO + + :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 NodeBalancer types that match the query. + :rtype: PaginatedList of NodeBalancerType + """ + + return self.client._get_and_filter( + NodeBalancerType, *filters, endpoint="/nodebalancers/types" + ) diff --git a/linode_api4/groups/volume.py b/linode_api4/groups/volume.py index edbfdfbf8..dc0d7c601 100644 --- a/linode_api4/groups/volume.py +++ b/linode_api4/groups/volume.py @@ -1,6 +1,6 @@ from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group -from linode_api4.objects import Base, Volume +from linode_api4.objects import Base, Volume, VolumeType class VolumeGroup(Group): @@ -71,3 +71,21 @@ def create(self, label, region=None, linode=None, size=20, **kwargs): v = Volume(self.client, result["id"], result) return v + + def types(self, *filters): + """ + Returns a :any:`PaginatedList` of :any:`VolumeType` objects that represents a valid Volume type. + + API Documentation: TODO + + :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 Volume types that match the query. + :rtype: PaginatedList of VolumeType + """ + + return self.client._get_and_filter( + VolumeType, *filters, endpoint="/volumes/types" + ) diff --git a/linode_api4/objects/__init__.py b/linode_api4/objects/__init__.py index 3ecce4584..b13fac51a 100644 --- a/linode_api4/objects/__init__.py +++ b/linode_api4/objects/__init__.py @@ -6,7 +6,7 @@ from .region import Region from .image import Image from .linode import * -from .volume import Volume +from .volume import * from .domain import * from .account import * from .networking import * diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index 7889c9c07..b6471553c 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -2,6 +2,7 @@ from typing import Any, Dict, List, Optional, Union from urllib import parse +from linode_api4.common import Price, RegionPrice from linode_api4.errors import UnexpectedResponseError from linode_api4.objects import ( Base, @@ -15,6 +16,24 @@ ) +class LKEType(Base): + """ + An LKEType represents the structure of a valid LKE type. + Currently the LKEType can only be retrieved by listing, i.e.: + types = client.lke.types() + + API documentation: TODO + """ + + properties = { + "id": Property(identifier=True), + "label": Property(), + "price": Property(json_object=Price), + "region_prices": Property(json_object=RegionPrice), + "transfer": Property(), + } + + class KubeVersion(Base): """ A KubeVersion is a version of Kubernetes that can be deployed on LKE. diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index 993961098..9e19b2d92 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -1,6 +1,7 @@ from dataclasses import dataclass from typing import Optional +from linode_api4.common import Price, RegionPrice from linode_api4.errors import UnexpectedResponseError from linode_api4.objects import Base, DerivedBase, JSONObject, Property, Region @@ -256,3 +257,21 @@ def device_create(self, id, type="linode", **kwargs): c = FirewallDevice(self._client, result["id"], self.id, result) return c + + +class NetworkTransferPrice(Base): + """ + An NetworkTransferPrice represents the structure of a valid network transfer price. + Currently the NetworkTransferPrice can only be retrieved by listing, i.e.: + types = client.networking.transfer_prices() + + API documentation: TODO + """ + + properties = { + "id": Property(identifier=True), + "label": Property(), + "price": Property(json_object=Price), + "region_prices": Property(json_object=RegionPrice), + "transfer": Property(), + } diff --git a/linode_api4/objects/nodebalancer.py b/linode_api4/objects/nodebalancer.py index 2aeb6180c..36d038bcb 100644 --- a/linode_api4/objects/nodebalancer.py +++ b/linode_api4/objects/nodebalancer.py @@ -1,6 +1,7 @@ import os from urllib import parse +from linode_api4.common import Price, RegionPrice from linode_api4.errors import UnexpectedResponseError from linode_api4.objects import ( Base, @@ -12,6 +13,24 @@ from linode_api4.objects.networking import Firewall, IPAddress +class NodeBalancerType(Base): + """ + An NodeBalancerType represents the structure of a valid NodeBalancer type. + Currently the NodeBalancerType can only be retrieved by listing, i.e.: + types = client.nodebalancers.types() + + API documentation: TODO + """ + + properties = { + "id": Property(identifier=True), + "label": Property(), + "price": Property(json_object=Price), + "region_prices": Property(json_object=RegionPrice), + "transfer": Property(), + } + + class NodeBalancerNode(DerivedBase): """ The information about a single Node, a backend for this NodeBalancer’s configured port. diff --git a/linode_api4/objects/volume.py b/linode_api4/objects/volume.py index 6b126cc75..a79e3174c 100644 --- a/linode_api4/objects/volume.py +++ b/linode_api4/objects/volume.py @@ -1,7 +1,26 @@ +from linode_api4.common import Price, RegionPrice from linode_api4.errors import UnexpectedResponseError from linode_api4.objects import Base, Instance, Property, Region +class VolumeType(Base): + """ + An VolumeType represents the structure of a valid Volume type. + Currently the VolumeType can only be retrieved by listing, i.e.: + types = client.volumes.types() + + API documentation: TODO + """ + + properties = { + "id": Property(identifier=True), + "label": Property(), + "price": Property(json_object=Price), + "region_prices": Property(json_object=RegionPrice), + "transfer": Property(), + } + + class Volume(Base): """ A single Block Storage Volume. Block Storage Volumes are persistent storage devices diff --git a/test/fixtures/lke_types.json b/test/fixtures/lke_types.json new file mode 100644 index 000000000..7d27a7f86 --- /dev/null +++ b/test/fixtures/lke_types.json @@ -0,0 +1,38 @@ +{ + "data": [ + { + "id": "lke-sa", + "label": "LKE Standard Availability", + "price": { + "hourly": 0, + "monthly": 0 + }, + "region_prices": [], + "transfer": 0 + }, + { + "id": "lke-ha", + "label": "LKE High Availability", + "price": { + "hourly": 0.09, + "monthly": 60 + }, + "region_prices": [ + { + "id": "id-cgk", + "hourly": 0.108, + "monthly": 72 + }, + { + "id": "br-gru", + "hourly": 0.126, + "monthly": 84 + } + ], + "transfer": 0 + } + ], + "page": 1, + "pages": 1, + "results": 2 +} \ No newline at end of file diff --git a/test/fixtures/network-transfer_prices.json b/test/fixtures/network-transfer_prices.json new file mode 100644 index 000000000..d595864ef --- /dev/null +++ b/test/fixtures/network-transfer_prices.json @@ -0,0 +1,38 @@ +{ + "data": [ + { + "id": "distributed_network_transfer", + "label": "Distributed Network Transfer", + "price": { + "hourly": 0.01, + "monthly": null + }, + "region_prices": [], + "transfer": 0 + }, + { + "id": "network_transfer", + "label": "Network Transfer", + "price": { + "hourly": 0.005, + "monthly": null + }, + "region_prices": [ + { + "id": "id-cgk", + "hourly": 0.015, + "monthly": null + }, + { + "id": "br-gru", + "hourly": 0.007, + "monthly": null + } + ], + "transfer": 0 + } + ], + "page": 1, + "pages": 1, + "results": 2 +} \ No newline at end of file diff --git a/test/fixtures/nodebalancers_types.json b/test/fixtures/nodebalancers_types.json new file mode 100644 index 000000000..9e5d3fa53 --- /dev/null +++ b/test/fixtures/nodebalancers_types.json @@ -0,0 +1,28 @@ +{ + "data": [ + { + "id": "nodebalancer", + "label": "NodeBalancer", + "price": { + "hourly": 0.015, + "monthly": 10 + }, + "region_prices": [ + { + "id": "id-cgk", + "hourly": 0.018, + "monthly": 12 + }, + { + "id": "br-gru", + "hourly": 0.021, + "monthly": 14 + } + ], + "transfer": 0 + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/fixtures/volumes_types.json b/test/fixtures/volumes_types.json new file mode 100644 index 000000000..9b975506e --- /dev/null +++ b/test/fixtures/volumes_types.json @@ -0,0 +1,28 @@ +{ + "data": [ + { + "id": "volume", + "label": "Storage Volume", + "price": { + "hourly": 0.00015, + "monthly": 0.1 + }, + "region_prices": [ + { + "id": "id-cgk", + "hourly": 0.00018, + "monthly": 0.12 + }, + { + "id": "br-gru", + "hourly": 0.00021, + "monthly": 0.14 + } + ], + "transfer": 0 + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index 2f659f9a7..eb31c8eb6 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -15,8 +15,14 @@ LKEClusterControlPlaneACLOptions, LKEClusterControlPlaneOptions, ) +from linode_api4.common import RegionPrice from linode_api4.errors import ApiError -from linode_api4.objects import LKECluster, LKENodePool, LKENodePoolTaint +from linode_api4.objects import ( + LKECluster, + LKENodePool, + LKENodePoolTaint, + LKEType, +) @pytest.fixture(scope="session") @@ -320,3 +326,22 @@ def test_lke_cluster_labels_and_taints(lke_cluster_with_labels_and_taints): assert vars(pool.labels) == updated_labels assert updated_taints[0] in pool.taints assert LKENodePoolTaint.from_json(updated_taints[1]) in pool.taints + + +def test_lke_types(test_linode_client): + types = test_linode_client.lke.types() + + if len(types) > 0: + for lke_type in types: + assert type(lke_type) is LKEType + assert lke_type.price.monthly is None or ( + isinstance(lke_type.price.monthly, (float, int)) + and lke_type.price.monthly >= 0 + ) + if len(lke_type.region_prices) > 0: + region_price = lke_type.region_prices[0] + assert type(region_price) is RegionPrice + assert lke_type.price.monthly is None or ( + isinstance(lke_type.price.monthly, (float, int)) + and lke_type.price.monthly >= 0 + ) diff --git a/test/integration/models/networking/test_networking.py b/test/integration/models/networking/test_networking.py index d9f13063e..3eb455cb4 100644 --- a/test/integration/models/networking/test_networking.py +++ b/test/integration/models/networking/test_networking.py @@ -3,6 +3,7 @@ import pytest from linode_api4.objects import Config, ConfigInterfaceIPv4, Firewall, IPAddress +from linode_api4.objects.networking import NetworkTransferPrice, Price @pytest.mark.smoke @@ -121,3 +122,15 @@ def test_ip_info_vpc(test_linode_client, create_vpc_with_subnet_and_linode): assert ip_info.vpc_nat_1_1.address == "10.0.0.2" assert ip_info.vpc_nat_1_1.vpc_id == vpc.id assert ip_info.vpc_nat_1_1.subnet_id == subnet.id + + +def test_network_transfer_prices(test_linode_client): + transfer_prices = test_linode_client.networking.transfer_prices() + + if len(transfer_prices) > 0: + assert type(transfer_prices[0]) is NetworkTransferPrice + assert type(transfer_prices[0].price) is Price + assert ( + transfer_prices[0].price is None + or transfer_prices[0].price.hourly >= 0 + ) diff --git a/test/integration/models/nodebalancer/test_nodebalancer.py b/test/integration/models/nodebalancer/test_nodebalancer.py index ab3095aaa..a3c00cee9 100644 --- a/test/integration/models/nodebalancer/test_nodebalancer.py +++ b/test/integration/models/nodebalancer/test_nodebalancer.py @@ -3,7 +3,12 @@ import pytest from linode_api4 import ApiError -from linode_api4.objects import NodeBalancerConfig, NodeBalancerNode +from linode_api4.objects import ( + NodeBalancerConfig, + NodeBalancerNode, + NodeBalancerType, + RegionPrice, +) @pytest.fixture(scope="session") @@ -121,3 +126,22 @@ def test_delete_nb_node(test_linode_client, create_nb_config): (create_nb_config.id, create_nb_config.nodebalancer_id), ) assert "Not Found" in str(e.json) + + +def test_nodebalancer_types(test_linode_client): + types = test_linode_client.nodebalancers.types() + + if len(types) > 0: + for nb_type in types: + assert type(nb_type) is NodeBalancerType + assert nb_type.price.monthly is None or ( + isinstance(nb_type.price.monthly, (float, int)) + and nb_type.price.monthly >= 0 + ) + if len(nb_type.region_prices) > 0: + region_price = nb_type.region_prices[0] + assert type(region_price) is RegionPrice + assert region_price.monthly is None or ( + isinstance(region_price.monthly, (float, int)) + and region_price.monthly >= 0 + ) diff --git a/test/integration/models/volume/test_volume.py b/test/integration/models/volume/test_volume.py index 08e836a13..820f7027a 100644 --- a/test/integration/models/volume/test_volume.py +++ b/test/integration/models/volume/test_volume.py @@ -9,7 +9,7 @@ import pytest from linode_api4 import ApiError, LinodeClient -from linode_api4.objects import Volume +from linode_api4.objects import RegionPrice, Volume, VolumeType @pytest.fixture(scope="session") @@ -121,3 +121,22 @@ def test_detach_volume_to_linode( # time wait for volume to detach before deletion occurs time.sleep(30) + + +def test_volume_types(test_linode_client): + types = test_linode_client.volumes.types() + + if len(types) > 0: + for volume_type in types: + assert type(volume_type) is VolumeType + assert volume_type.price.monthly is None or ( + isinstance(volume_type.price.monthly, (float, int)) + and volume_type.price.monthly >= 0 + ) + if len(volume_type.region_prices) > 0: + region_price = volume_type.region_prices[0] + assert type(region_price) is RegionPrice + assert region_price.monthly is None or ( + isinstance(region_price.monthly, (float, int)) + and region_price.monthly >= 0 + ) diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index 84c003e97..357826c0a 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -720,6 +720,19 @@ def test_cluster_create_with_api_objects(self): self.assertEqual(cluster.region.id, "ap-west") self.assertEqual(cluster.k8s_version.id, "1.19") + def test_lke_types(self): + """ + Tests that a list of LKETypes can be retrieved + """ + types = self.client.lke.types() + self.assertEqual(len(types), 2) + self.assertEqual(types[1].id, "lke-ha") + self.assertEqual(types[1].price.hourly, 0.09) + self.assertEqual(types[1].price.monthly, 60) + self.assertEqual(types[1].region_prices[0].id, "id-cgk") + self.assertEqual(types[1].region_prices[0].hourly, 0.108) + self.assertEqual(types[1].region_prices[0].monthly, 72) + def test_cluster_create_with_string_repr(self): """ Tests clusters can be created using string representations @@ -1236,3 +1249,57 @@ def test_ipv6_ranges(self): ranges = self.client.networking.ipv6_ranges() self.assertEqual(len(ranges), 1) self.assertEqual(ranges[0].range, "2600:3c01::") + + def test_network_transfer_prices(self): + """ + Tests that a list of NetworkTransferPrices can be retrieved + """ + transfer_prices = self.client.networking.transfer_prices() + self.assertEqual(len(transfer_prices), 2) + self.assertEqual(transfer_prices[1].id, "network_transfer") + self.assertEqual(transfer_prices[1].price.hourly, 0.005) + self.assertEqual(transfer_prices[1].price.monthly, None) + self.assertEqual(len(transfer_prices[1].region_prices), 2) + self.assertEqual(transfer_prices[1].region_prices[0].id, "id-cgk") + self.assertEqual(transfer_prices[1].region_prices[0].hourly, 0.015) + self.assertEqual(transfer_prices[1].region_prices[0].monthly, None) + + +class NodeBalancerGroupTest(ClientBaseCase): + """ + Tests methods of the NodeBalancerGroup + """ + + def test_nodebalancer_types(self): + """ + Tests that a list of NodebalancerTypes can be retrieved + """ + types = self.client.nodebalancers.types() + self.assertEqual(len(types), 1) + self.assertEqual(types[0].id, "nodebalancer") + self.assertEqual(types[0].price.hourly, 0.015) + self.assertEqual(types[0].price.monthly, 10) + self.assertEqual(len(types[0].region_prices), 2) + self.assertEqual(types[0].region_prices[0].id, "id-cgk") + self.assertEqual(types[0].region_prices[0].hourly, 0.018) + self.assertEqual(types[0].region_prices[0].monthly, 12) + + +class VolumeGroupTest(ClientBaseCase): + """ + Tests methods of the VolumeGroup + """ + + def test_volume_types(self): + """ + Tests that a list of VolumeTypes can be retrieved + """ + types = self.client.volumes.types() + self.assertEqual(len(types), 1) + self.assertEqual(types[0].id, "volume") + self.assertEqual(types[0].price.hourly, 0.00015) + self.assertEqual(types[0].price.monthly, 0.1) + self.assertEqual(len(types[0].region_prices), 2) + self.assertEqual(types[0].region_prices[0].id, "id-cgk") + self.assertEqual(types[0].region_prices[0].hourly, 0.00018) + self.assertEqual(types[0].region_prices[0].monthly, 0.12)