diff --git a/linode_api4/__init__.py b/linode_api4/__init__.py index b347b607..69fa1111 100644 --- a/linode_api4/__init__.py +++ b/linode_api4/__init__.py @@ -1,7 +1,7 @@ # isort: skip_file from linode_api4.objects import * from linode_api4.errors import ApiError, UnexpectedResponseError -from linode_api4.linode_client import LinodeClient +from linode_api4.linode_client import LinodeClient, MonitorClient from linode_api4.login_client import LinodeLoginClient, OAuthScopes from linode_api4.paginated_list import PaginatedList from linode_api4.polling import EventPoller diff --git a/linode_api4/groups/__init__.py b/linode_api4/groups/__init__.py index 3842042a..4096cd21 100644 --- a/linode_api4/groups/__init__.py +++ b/linode_api4/groups/__init__.py @@ -11,6 +11,7 @@ from .lke_tier import * from .longview import * from .monitor import * +from .monitor_api import * from .networking import * from .nodebalancer import * from .object_storage import * diff --git a/linode_api4/groups/group.py b/linode_api4/groups/group.py index c591b7fd..b7c0e1ee 100644 --- a/linode_api4/groups/group.py +++ b/linode_api4/groups/group.py @@ -3,9 +3,9 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from linode_api4 import LinodeClient + from linode_api4.linode_client import BaseClient class Group: - def __init__(self, client: LinodeClient): + def __init__(self, client: BaseClient): self.client = client diff --git a/linode_api4/groups/monitor.py b/linode_api4/groups/monitor.py index 908b4e81..7164a6e5 100644 --- a/linode_api4/groups/monitor.py +++ b/linode_api4/groups/monitor.py @@ -3,9 +3,7 @@ ] from typing import Any, Optional -from linode_api4 import ( - PaginatedList, -) +from linode_api4 import PaginatedList from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group from linode_api4.objects import ( diff --git a/linode_api4/groups/monitor_api.py b/linode_api4/groups/monitor_api.py new file mode 100644 index 00000000..6eeb2bcb --- /dev/null +++ b/linode_api4/groups/monitor_api.py @@ -0,0 +1,50 @@ +__all__ = [ + "MetricsGroup", +] + +from typing import Optional + +from linode_api4.groups import Group +from linode_api4.objects.monitor_api import EntityMetrics + + +class MetricsGroup(Group): + """ + Encapsulates Monitor-related methods of the :any:`MonitorClient`. + + This group contains all features related to metrics in the API monitor-api. + """ + + def fetch_metrics( + self, + service_type: str, + entity_ids: list, + **kwargs, + ) -> Optional[EntityMetrics]: + """ + Returns metrics information for the individual entities within a specific service type. + + API documentation: https://techdocs.akamai.com/linode-api/reference/post-read-metric + + :param service_type: The service being monitored. + Currently, only the Managed Databases (dbaas) service type is supported. + :type service_type: str + + :param entity_ids: The id for each individual entity from a service_type. + :type entity_ids: list + + :param kwargs: Any other arguments accepted by the api. Please refer to the API documentation for full info. + + :returns: Service metrics requested. + :rtype: EntityMetrics or None + """ + + params = {"entity_ids": entity_ids} + + params.update(kwargs) + + result = self.client.post( + f"/monitor/services/{service_type}/metrics", data=params + ) + + return EntityMetrics.from_json(result) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index e71f1563..5390e7ed 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -19,6 +19,7 @@ LinodeGroup, LKEGroup, LongviewGroup, + MetricsGroup, MonitorGroup, NetworkingGroup, NodeBalancerGroup, @@ -51,11 +52,11 @@ def get_backoff_time(self): return self.backoff_factor -class LinodeClient: +class BaseClient: def __init__( self, token, - base_url="https://api.linode.com/v4", + base_url, user_agent=None, page_size=None, retry=True, @@ -64,42 +65,6 @@ def __init__( retry_statuses=None, ca_path=None, ): - """ - The main interface to the Linode API. - - :param token: The authentication token to use for communication with the - API. Can be either a Personal Access Token or an OAuth Token. - :type token: str - :param base_url: The base URL for API requests. Generally, you shouldn't - change this. - :type base_url: str - :param user_agent: What to append to the User Agent of all requests made - by this client. Setting this allows Linode's internal - monitoring applications to track the usage of your - application. Setting this is not necessary, but some - applications may desire this behavior. - :type user_agent: str - :param page_size: The default size to request pages at. If not given, - the API's default page size is used. Valid values - can be found in the API docs, but at time of writing - are between 25 and 500. - :type page_size: int - :param retry: Whether API requests should automatically be retries on known - intermittent responses. - :type retry: bool - :param retry_rate_limit_interval: The amount of time to wait between HTTP request - retries. - :type retry_rate_limit_interval: Union[float, int] - :param retry_max: The number of request retries that should be attempted before - raising an API error. - :type retry_max: int - :type retry_statuses: List of int - :param retry_statuses: Additional HTTP response statuses to retry on. - By default, the client will retry on 408, 429, and 502 - responses. - :param ca_path: The path to a CA file to use for API requests in this client. - :type ca_path: str - """ self.base_url = base_url self._add_user_agent = user_agent self.token = token @@ -138,72 +103,6 @@ def __init__( self.session.mount("http://", retry_adapter) self.session.mount("https://", retry_adapter) - #: Access methods related to Linodes - see :any:`LinodeGroup` for - #: more information - self.linode = LinodeGroup(self) - - #: Access methods related to your user - see :any:`ProfileGroup` for - #: more information - self.profile = ProfileGroup(self) - - #: Access methods related to your account - see :any:`AccountGroup` for - #: more information - self.account = AccountGroup(self) - - #: Access methods related to networking on your account - see - #: :any:`NetworkingGroup` for more information - self.networking = NetworkingGroup(self) - - #: Access methods related to support - see :any:`SupportGroup` for more - #: information - self.support = SupportGroup(self) - - #: Access information related to the Longview service - see - #: :any:`LongviewGroup` for more information - self.longview = LongviewGroup(self) - - #: Access methods related to Object Storage - see :any:`ObjectStorageGroup` - #: for more information - self.object_storage = ObjectStorageGroup(self) - - #: Access methods related to LKE - see :any:`LKEGroup` for more information. - self.lke = LKEGroup(self) - - #: Access methods related to Managed Databases - see :any:`DatabaseGroup` for more information. - self.database = DatabaseGroup(self) - - #: Access methods related to NodeBalancers - see :any:`NodeBalancerGroup` for more information. - self.nodebalancers = NodeBalancerGroup(self) - - #: Access methods related to Domains - see :any:`DomainGroup` for more information. - self.domains = DomainGroup(self) - - #: Access methods related to Tags - See :any:`TagGroup` for more information. - self.tags = TagGroup(self) - - #: Access methods related to Volumes - See :any:`VolumeGroup` for more information. - self.volumes = VolumeGroup(self) - - #: Access methods related to Regions - See :any:`RegionGroup` for more information. - self.regions = RegionGroup(self) - - #: Access methods related to Images - See :any:`ImageGroup` for more information. - self.images = ImageGroup(self) - - #: Access methods related to VPCs - See :any:`VPCGroup` for more information. - self.vpcs = VPCGroup(self) - - #: Access methods related to Event polling - See :any:`PollingGroup` for more information. - self.polling = PollingGroup(self) - - #: Access methods related to Beta Program - See :any:`BetaProgramGroup` for more information. - self.beta = BetaProgramGroup(self) - - #: Access methods related to VM placement - See :any:`PlacementAPIGroup` for more information. - self.placement = PlacementAPIGroup(self) - - self.monitor = MonitorGroup(self) - @property def _user_agent(self): return "{}python-linode_api4/{} {}".format( @@ -367,6 +266,164 @@ def __setattr__(self, key, value): super().__setattr__(key, value) + # helper functions + def _get_and_filter( + self, + obj_type, + *filters, + endpoint=None, + parent_id=None, + ): + parsed_filters = None + if filters: + if len(filters) > 1: + parsed_filters = and_( + *filters + ).dct # pylint: disable=no-value-for-parameter + else: + parsed_filters = filters[0].dct + + # Use sepcified endpoint + if endpoint: + 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, + parent_id=parent_id, + filters=parsed_filters, + ) + + +class LinodeClient(BaseClient): + def __init__( + self, + token, + base_url="https://api.linode.com/v4", + user_agent=None, + page_size=None, + retry=True, + retry_rate_limit_interval=1.0, + retry_max=5, + retry_statuses=None, + ca_path=None, + ): + """ + The main interface to the Linode API. + + :param token: The authentication token to use for communication with the + API. Can be either a Personal Access Token or an OAuth Token. + :type token: str + :param base_url: The base URL for API requests. Generally, you shouldn't + change this. + :type base_url: str + :param user_agent: What to append to the User Agent of all requests made + by this client. Setting this allows Linode's internal + monitoring applications to track the usage of your + application. Setting this is not necessary, but some + applications may desire this behavior. + :type user_agent: str + :param page_size: The default size to request pages at. If not given, + the API's default page size is used. Valid values + can be found in the API docs, but at time of writing + are between 25 and 500. + :type page_size: int + :param retry: Whether API requests should automatically be retries on known + intermittent responses. + :type retry: bool + :param retry_rate_limit_interval: The amount of time to wait between HTTP request + retries. + :type retry_rate_limit_interval: Union[float, int] + :param retry_max: The number of request retries that should be attempted before + raising an API error. + :type retry_max: int + :type retry_statuses: List of int + :param retry_statuses: Additional HTTP response statuses to retry on. + By default, the client will retry on 408, 429, and 502 + responses. + :param ca_path: The path to a CA file to use for API requests in this client. + :type ca_path: str + """ + #: Access methods related to Linodes - see :any:`LinodeGroup` for + #: more information + self.linode = LinodeGroup(self) + + #: Access methods related to your user - see :any:`ProfileGroup` for + #: more information + self.profile = ProfileGroup(self) + + #: Access methods related to your account - see :any:`AccountGroup` for + #: more information + self.account = AccountGroup(self) + + #: Access methods related to networking on your account - see + #: :any:`NetworkingGroup` for more information + self.networking = NetworkingGroup(self) + + #: Access methods related to support - see :any:`SupportGroup` for more + #: information + self.support = SupportGroup(self) + + #: Access information related to the Longview service - see + #: :any:`LongviewGroup` for more information + self.longview = LongviewGroup(self) + + #: Access methods related to Object Storage - see :any:`ObjectStorageGroup` + #: for more information + self.object_storage = ObjectStorageGroup(self) + + #: Access methods related to LKE - see :any:`LKEGroup` for more information. + self.lke = LKEGroup(self) + + #: Access methods related to Managed Databases - see :any:`DatabaseGroup` for more information. + self.database = DatabaseGroup(self) + + #: Access methods related to NodeBalancers - see :any:`NodeBalancerGroup` for more information. + self.nodebalancers = NodeBalancerGroup(self) + + #: Access methods related to Domains - see :any:`DomainGroup` for more information. + self.domains = DomainGroup(self) + + #: Access methods related to Tags - See :any:`TagGroup` for more information. + self.tags = TagGroup(self) + + #: Access methods related to Volumes - See :any:`VolumeGroup` for more information. + self.volumes = VolumeGroup(self) + + #: Access methods related to Regions - See :any:`RegionGroup` for more information. + self.regions = RegionGroup(self) + + #: Access methods related to Images - See :any:`ImageGroup` for more information. + self.images = ImageGroup(self) + + #: Access methods related to VPCs - See :any:`VPCGroup` for more information. + self.vpcs = VPCGroup(self) + + #: Access methods related to Event polling - See :any:`PollingGroup` for more information. + self.polling = PollingGroup(self) + + #: Access methods related to Beta Program - See :any:`BetaProgramGroup` for more information. + self.beta = BetaProgramGroup(self) + + #: Access methods related to VM placement - See :any:`PlacementAPIGroup` for more information. + self.placement = PlacementAPIGroup(self) + + self.monitor = MonitorGroup(self) + + super().__init__( + token=token, + base_url=base_url, + user_agent=user_agent, + page_size=page_size, + retry=retry, + retry_rate_limit_interval=retry_rate_limit_interval, + retry_max=retry_max, + retry_statuses=retry_statuses, + ca_path=ca_path, + ) + def image_create(self, disk, label=None, description=None, tags=None): """ .. note:: This method is an alias to maintain backwards compatibility. @@ -457,32 +514,59 @@ def volume_create(self, label, region=None, linode=None, size=20, **kwargs): label, region=region, linode=linode, size=size, **kwargs ) - # helper functions - def _get_and_filter( + +class MonitorClient(BaseClient): + """ + The main interface to the Monitor API. + + :param token: The authentication Personal Access Token token to use for + communication with the API. You may want to generate one using + Linode Client. For example: + linode_client.monitor.create_token( + service_type="dbaas", entity_ids=[entity_id] + ) + :type token: str + :param base_url: The base URL for monitor API requests. Generally, you shouldn't + change this. + :type base_url: str + :param user_agent: What to append to the User Agent of all requests made + by this client. Setting this allows Linode's internal + monitoring applications to track the usage of your + application. Setting this is not necessary, but some + applications may desire this behavior. + :type user_agent: str + :param page_size: The default size to request pages at. If not given, + the API's default page size is used. Valid values + can be found in the API docs. + :type page_size: int + :param ca_path: The path to a CA file to use for API requests in this client. + :type ca_path: str + """ + + def __init__( self, - obj_type, - *filters, - endpoint=None, - parent_id=None, + token, + base_url="https://monitor-api.linode.com/v2beta", + user_agent=None, + page_size=None, + ca_path=None, + retry=True, + retry_rate_limit_interval=1.0, + retry_max=5, + retry_statuses=None, ): - parsed_filters = None - if filters: - if len(filters) > 1: - parsed_filters = and_( - *filters - ).dct # pylint: disable=no-value-for-parameter - else: - parsed_filters = filters[0].dct - - # Use sepcified endpoint - if endpoint: - 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, - parent_id=parent_id, - filters=parsed_filters, - ) + #: Access methods related to your monitor metrics - see :any:`MetricsGroup` for + #: more information + self.metrics = MetricsGroup(self) + + super().__init__( + token=token, + base_url=base_url, + user_agent=user_agent, + page_size=page_size, + retry=retry, + retry_rate_limit_interval=retry_rate_limit_interval, + retry_max=retry_max, + retry_statuses=retry_statuses, + ca_path=ca_path, + ) diff --git a/linode_api4/objects/__init__.py b/linode_api4/objects/__init__.py index 7f1542d2..c847024d 100644 --- a/linode_api4/objects/__init__.py +++ b/linode_api4/objects/__init__.py @@ -22,3 +22,4 @@ from .beta import * from .placement import * from .monitor import * +from .monitor_api import * diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index f518e641..80d2d92a 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -7,7 +7,8 @@ from dataclasses import dataclass, field from typing import List, Optional -from linode_api4.objects import Base, JSONObject, Property, StrEnum +from linode_api4.objects.base import Base, Property +from linode_api4.objects.serializable import JSONObject, StrEnum class AggregateFunction(StrEnum): diff --git a/linode_api4/objects/monitor_api.py b/linode_api4/objects/monitor_api.py new file mode 100644 index 00000000..347aa3d3 --- /dev/null +++ b/linode_api4/objects/monitor_api.py @@ -0,0 +1,36 @@ +__all__ = [ + "EntityMetrics", + "EntityMetricsData", + "EntityMetricsDataResult", + "EntityMetricsStats", +] +from dataclasses import dataclass, field +from typing import List, Optional + +from linode_api4.objects.serializable import JSONObject + + +@dataclass +class EntityMetricsStats(JSONObject): + executionTimeMsec: int = 0 + seriesFetched: str = "" + + +@dataclass +class EntityMetricsDataResult(JSONObject): + metric: dict = field(default_factory=dict) + values: list = field(default_factory=list) + + +@dataclass +class EntityMetricsData(JSONObject): + result: Optional[List[EntityMetricsDataResult]] = None + resultType: str = "" + + +@dataclass +class EntityMetrics(JSONObject): + data: Optional[EntityMetricsData] = None + isPartial: bool = False + stats: Optional[EntityMetricsStats] = None + status: str = "" diff --git a/test/fixtures/monitor_services_dbaas_metrics.json b/test/fixtures/monitor_services_dbaas_metrics.json new file mode 100644 index 00000000..67657cb7 --- /dev/null +++ b/test/fixtures/monitor_services_dbaas_metrics.json @@ -0,0 +1,47 @@ +{ + "data": { + "result": [ + { + "metric": { + "entity_id": 13316, + "metric_name": "avg_read_iops", + "node_id": "primary-9" + }, + "values": [ + [ + 1728996500, + "90.55555555555556" + ], + [ + 1729043400, + "14890.583333333334" + ] + ] + }, + { + "metric": { + "entity_id": 13217, + "metric_name": "avg_cpu_usage", + "node_id": "primary-0" + }, + "values": [ + [ + 1728996500, + "12.45" + ], + [ + 1729043400, + "18.67" + ] + ] + } + ], + "resultType": "matrix" + }, + "isPartial": false, + "stats": { + "executionTimeMsec": 21, + "seriesFetched": "2" + }, + "status": "success" +} \ No newline at end of file diff --git a/test/unit/base.py b/test/unit/base.py index e143f8f6..bc0ec2f0 100644 --- a/test/unit/base.py +++ b/test/unit/base.py @@ -4,7 +4,7 @@ from mock import patch -from linode_api4 import LinodeClient +from linode_api4 import LinodeClient, MonitorClient FIXTURES = TestFixtures() @@ -202,3 +202,29 @@ def mock_delete(self): mocked requests """ return MethodMock("delete", {}) + + +class MonitorClientBaseCase(TestCase): + def setUp(self): + self.client = MonitorClient("testing", base_url="/") + + self.get_patch = patch( + "linode_api4.linode_client.requests.Session.get", + side_effect=mock_get, + ) + self.get_patch.start() + + def tearDown(self): + self.get_patch.stop() + + def mock_post(self, return_dct): + """ + Returns a MethodMock mocking a POST. This should be used in a with + statement. + + :param return_dct: The JSON that should be returned from this POST + + :returns: A MethodMock object who will capture the parameters of the + mocked requests + """ + return MethodMock("post", return_dct) diff --git a/test/unit/groups/monitor_api_test.py b/test/unit/groups/monitor_api_test.py new file mode 100644 index 00000000..eb28fdde --- /dev/null +++ b/test/unit/groups/monitor_api_test.py @@ -0,0 +1,47 @@ +from test.unit.base import MonitorClientBaseCase + + +class MonitorAPITest(MonitorClientBaseCase): + """ + Tests methods of the Monitor API group + """ + + def test_fetch_metrics(self): + service_type = "dbaas" + url = f"/monitor/services/{service_type}/metrics" + with self.mock_post(url) as m: + metrics = self.client.metrics.fetch_metrics( + service_type, + entity_ids=[13217, 13316], + metrics=[ + {"name": "avg_read_iops", "aggregate": "avg"}, + {"name": "avg_cpu_usage", "aggregate": "avg"}, + ], + relative_time_duration={"unit": "hr", "value": 1}, + ) + + # assert call data + assert m.call_url == url + assert m.call_data == { + "entity_ids": [13217, 13316], + "metrics": [ + {"name": "avg_read_iops", "aggregate": "avg"}, + {"name": "avg_cpu_usage", "aggregate": "avg"}, + ], + "relative_time_duration": {"unit": "hr", "value": 1}, + } + + # assert the metrics data + metric_data = metrics.data.result[0] + + assert metrics.data.resultType == "matrix" + assert metric_data.metric["entity_id"] == 13316 + assert metric_data.metric["metric_name"] == "avg_read_iops" + assert metric_data.metric["node_id"] == "primary-9" + assert metric_data.values[0][0] == 1728996500 + assert metric_data.values[0][1] == "90.55555555555556" + + assert metrics.status == "success" + assert metrics.stats.executionTimeMsec == 21 + assert metrics.stats.seriesFetched == "2" + assert not metrics.isPartial