diff --git a/examples/get_server_metrics.py b/examples/get_server_metrics.py new file mode 100644 index 0000000..cf91eae --- /dev/null +++ b/examples/get_server_metrics.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import json +from datetime import datetime, timedelta +from os import environ + +from hcloud import Client +from hcloud.images import Image +from hcloud.server_types import ServerType + +assert ( + "HCLOUD_TOKEN" in environ +), "Please export your API token in the HCLOUD_TOKEN environment variable" +token = environ["HCLOUD_TOKEN"] + +client = Client(token=token) + +server = client.servers.get_by_name("my-server") +if server is None: + response = client.servers.create( + name="my-server", + server_type=ServerType("cx11"), + image=Image(name="ubuntu-22.04"), + ) + server = response.server + +end = datetime.now() +start = end - timedelta(hours=1) + +response = server.get_metrics( + type=["cpu", "network"], + start=start, + end=end, +) + +print(json.dumps(response.metrics)) diff --git a/hcloud/load_balancers/__init__.py b/hcloud/load_balancers/__init__.py index 4ac79ce..4bfd799 100644 --- a/hcloud/load_balancers/__init__.py +++ b/hcloud/load_balancers/__init__.py @@ -7,6 +7,7 @@ ) from .domain import ( # noqa: F401 CreateLoadBalancerResponse, + GetMetricsResponse, IPv4Address, IPv6Network, LoadBalancer, diff --git a/hcloud/load_balancers/client.py b/hcloud/load_balancers/client.py index 56b93c8..e0635ee 100644 --- a/hcloud/load_balancers/client.py +++ b/hcloud/load_balancers/client.py @@ -1,16 +1,21 @@ from __future__ import annotations +from datetime import datetime from typing import TYPE_CHECKING, Any, NamedTuple +from dateutil.parser import isoparse + from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient from ..certificates import BoundCertificate from ..core import BoundModelBase, ClientEntityBase, Meta from ..load_balancer_types import BoundLoadBalancerType from ..locations import BoundLocation +from ..metrics import Metrics from ..networks import BoundNetwork from ..servers import BoundServer from .domain import ( CreateLoadBalancerResponse, + GetMetricsResponse, IPv4Address, IPv6Network, LoadBalancer, @@ -23,6 +28,7 @@ LoadBalancerTargetHealthStatus, LoadBalancerTargetIP, LoadBalancerTargetLabelSelector, + MetricsType, PrivateNet, PublicNetwork, ) @@ -177,6 +183,28 @@ def delete(self) -> bool: """ return self._client.delete(self) + def get_metrics( + self, + type: MetricsType, + start: datetime | str, + end: datetime | str, + step: float | None = None, + ) -> GetMetricsResponse: + """Get Metrics for a LoadBalancer. + + :param type: Type of metrics to get. + :param start: Start of period to get Metrics for (in ISO-8601 format). + :param end: End of period to get Metrics for (in ISO-8601 format). + :param step: Resolution of results in seconds. + """ + return self._client.get_metrics( + self, + type=type, + start=start, + end=end, + step=step, + ) + def get_actions_list( self, status: list[str] | None = None, @@ -533,6 +561,46 @@ def delete(self, load_balancer: LoadBalancer | BoundLoadBalancer) -> bool: ) return True + def get_metrics( + self, + load_balancer: LoadBalancer | BoundLoadBalancer, + type: MetricsType | list[MetricsType], + start: datetime | str, + end: datetime | str, + step: float | None = None, + ) -> GetMetricsResponse: + """Get Metrics for a LoadBalancer. + + :param load_balancer: The Load Balancer to get the metrics for. + :param type: Type of metrics to get. + :param start: Start of period to get Metrics for (in ISO-8601 format). + :param end: End of period to get Metrics for (in ISO-8601 format). + :param step: Resolution of results in seconds. + """ + if not isinstance(type, list): + type = [type] + if isinstance(start, str): + start = isoparse(start) + if isinstance(end, str): + end = isoparse(end) + + params: dict[str, Any] = { + "type": ",".join(type), + "start": start.isoformat(), + "end": end.isoformat(), + } + if step is not None: + params["step"] = step + + response = self._client.request( + url=f"/load_balancers/{load_balancer.id}/metrics", + method="GET", + params=params, + ) + return GetMetricsResponse( + metrics=Metrics(**response["metrics"]), + ) + def get_actions_list( self, load_balancer: LoadBalancer | BoundLoadBalancer, diff --git a/hcloud/load_balancers/domain.py b/hcloud/load_balancers/domain.py index 212bbdc..2d480ee 100644 --- a/hcloud/load_balancers/domain.py +++ b/hcloud/load_balancers/domain.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal from dateutil.parser import isoparse @@ -11,6 +11,7 @@ from ..certificates import BoundCertificate from ..load_balancer_types import BoundLoadBalancerType from ..locations import BoundLocation + from ..metrics import Metrics from ..networks import BoundNetwork from ..servers import BoundServer from .client import BoundLoadBalancer @@ -508,3 +509,26 @@ def __init__( ): self.load_balancer = load_balancer self.action = action + + +MetricsType = Literal[ + "open_connections", + "connections_per_second", + "requests_per_second", + "bandwidth", +] + + +class GetMetricsResponse(BaseDomain): + """Get a Load Balancer Metrics Response Domain + + :param metrics: The Load Balancer metrics + """ + + __slots__ = ("metrics",) + + def __init__( + self, + metrics: Metrics, + ): + self.metrics = metrics diff --git a/hcloud/metrics/__init__.py b/hcloud/metrics/__init__.py new file mode 100644 index 0000000..65d393c --- /dev/null +++ b/hcloud/metrics/__init__.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +from .domain import Metrics, TimeSeries # noqa: F401 diff --git a/hcloud/metrics/domain.py b/hcloud/metrics/domain.py new file mode 100644 index 0000000..4693f15 --- /dev/null +++ b/hcloud/metrics/domain.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Dict, List, Literal, Tuple + +from dateutil.parser import isoparse + +from ..core import BaseDomain + +TimeSeries = Dict[str, Dict[Literal["values"], List[Tuple[float, str]]]] + + +class Metrics(BaseDomain): + """Metrics Domain + + :param start: Start of period of metrics reported. + :param end: End of period of metrics reported. + :param step: Resolution of results in seconds. + :param time_series: Dict with time series data, using the name of the time series as + key. The metrics timestamps and values are stored in a list of tuples + ``[(timestamp, value), ...]``. + """ + + start: datetime + end: datetime + step: float + time_series: TimeSeries + + __slots__ = ( + "start", + "end", + "step", + "time_series", + ) + + def __init__( + self, + start: str, + end: str, + step: float, + time_series: TimeSeries, + ): + self.start = isoparse(start) + self.end = isoparse(end) + self.step = step + self.time_series = time_series diff --git a/hcloud/servers/__init__.py b/hcloud/servers/__init__.py index a7a61d4..58c811e 100644 --- a/hcloud/servers/__init__.py +++ b/hcloud/servers/__init__.py @@ -4,6 +4,7 @@ from .domain import ( # noqa: F401 CreateServerResponse, EnableRescueResponse, + GetMetricsResponse, IPv4Address, IPv6Network, PrivateNet, diff --git a/hcloud/servers/client.py b/hcloud/servers/client.py index b6da0d3..e4b2679 100644 --- a/hcloud/servers/client.py +++ b/hcloud/servers/client.py @@ -1,8 +1,11 @@ from __future__ import annotations import warnings +from datetime import datetime from typing import TYPE_CHECKING, Any, NamedTuple +from dateutil.parser import isoparse + from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient from ..core import BoundModelBase, ClientEntityBase, Meta from ..datacenters import BoundDatacenter @@ -10,6 +13,7 @@ from ..floating_ips import BoundFloatingIP from ..images import BoundImage, CreateImageResponse from ..isos import BoundIso +from ..metrics import Metrics from ..placement_groups import BoundPlacementGroup from ..primary_ips import BoundPrimaryIP from ..server_types import BoundServerType @@ -17,8 +21,10 @@ from .domain import ( CreateServerResponse, EnableRescueResponse, + GetMetricsResponse, IPv4Address, IPv6Network, + MetricsType, PrivateNet, PublicNetwork, PublicNetworkFirewall, @@ -210,6 +216,29 @@ def update( """ return self._client.update(self, name, labels) + def get_metrics( + self, + type: MetricsType | list[MetricsType], + start: datetime | str, + end: datetime | str, + step: float | None = None, + ) -> GetMetricsResponse: + """Get Metrics for a Server. + + :param server: The Server to get the metrics for. + :param type: Type of metrics to get. + :param start: Start of period to get Metrics for (in ISO-8601 format). + :param end: End of period to get Metrics for (in ISO-8601 format). + :param step: Resolution of results in seconds. + """ + return self._client.get_metrics( + self, + type=type, + start=start, + end=end, + step=step, + ) + def delete(self) -> BoundAction: """Deletes a server. This immediately removes the server from your account, and it is no longer accessible. @@ -742,6 +771,46 @@ def update( ) return BoundServer(self, response["server"]) + def get_metrics( + self, + server: Server | BoundServer, + type: MetricsType | list[MetricsType], + start: datetime | str, + end: datetime | str, + step: float | None = None, + ) -> GetMetricsResponse: + """Get Metrics for a Server. + + :param server: The Server to get the metrics for. + :param type: Type of metrics to get. + :param start: Start of period to get Metrics for (in ISO-8601 format). + :param end: End of period to get Metrics for (in ISO-8601 format). + :param step: Resolution of results in seconds. + """ + if not isinstance(type, list): + type = [type] + if isinstance(start, str): + start = isoparse(start) + if isinstance(end, str): + end = isoparse(end) + + params: dict[str, Any] = { + "type": ",".join(type), + "start": start.isoformat(), + "end": end.isoformat(), + } + if step is not None: + params["step"] = step + + response = self._client.request( + url=f"/servers/{server.id}/metrics", + method="GET", + params=params, + ) + return GetMetricsResponse( + metrics=Metrics(**response["metrics"]), + ) + def delete(self, server: Server | BoundServer) -> BoundAction: """Deletes a server. This immediately removes the server from your account, and it is no longer accessible. diff --git a/hcloud/servers/domain.py b/hcloud/servers/domain.py index 3802020..29afecf 100644 --- a/hcloud/servers/domain.py +++ b/hcloud/servers/domain.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal from dateutil.parser import isoparse @@ -13,6 +13,7 @@ from ..floating_ips import BoundFloatingIP from ..images import BoundImage from ..isos import BoundIso + from ..metrics import Metrics from ..networks import BoundNetwork from ..placement_groups import BoundPlacementGroup from ..primary_ips import BoundPrimaryIP, PrimaryIP @@ -427,3 +428,25 @@ def __init__( self.ipv6 = ipv6 self.enable_ipv4 = enable_ipv4 self.enable_ipv6 = enable_ipv6 + + +MetricsType = Literal[ + "cpu", + "disk", + "network", +] + + +class GetMetricsResponse(BaseDomain): + """Get a Server Metrics Response Domain + + :param metrics: The Server metrics + """ + + __slots__ = ("metrics",) + + def __init__( + self, + metrics: Metrics, + ): + self.metrics = metrics diff --git a/tests/unit/load_balancers/conftest.py b/tests/unit/load_balancers/conftest.py index dff89b1..f19508e 100644 --- a/tests/unit/load_balancers/conftest.py +++ b/tests/unit/load_balancers/conftest.py @@ -444,6 +444,26 @@ def response_simple_load_balancers(): } +@pytest.fixture() +def response_get_metrics(): + return { + "metrics": { + "start": "2023-12-14T16:55:32+01:00", + "end": "2023-12-14T17:25:32+01:00", + "step": 9.0, + "time_series": { + "requests_per_second": { + "values": [ + [1702571114, "0.000000"], + [1702571123, "0.000000"], + [1702571132, "0.000000"], + ] + } + }, + } + } + + @pytest.fixture() def response_add_service(): return { diff --git a/tests/unit/load_balancers/test_client.py b/tests/unit/load_balancers/test_client.py index 191334c..3e9f281 100644 --- a/tests/unit/load_balancers/test_client.py +++ b/tests/unit/load_balancers/test_client.py @@ -96,6 +96,30 @@ def test_delete(self, hetzner_client, generic_action, bound_load_balancer): assert delete_success is True + def test_get_metrics( + self, + hetzner_client, + response_get_metrics, + bound_load_balancer: BoundLoadBalancer, + ): + hetzner_client.request.return_value = response_get_metrics + response = bound_load_balancer.get_metrics( + type=["requests_per_second"], + start="2023-12-14T16:55:32+01:00", + end="2023-12-14T16:55:32+01:00", + ) + hetzner_client.request.assert_called_with( + url="/load_balancers/14/metrics", + method="GET", + params={ + "type": "requests_per_second", + "start": "2023-12-14T16:55:32+01:00", + "end": "2023-12-14T16:55:32+01:00", + }, + ) + assert "requests_per_second" in response.metrics.time_series + assert len(response.metrics.time_series["requests_per_second"]["values"]) == 3 + def test_add_service( self, hetzner_client, response_add_service, bound_load_balancer ): diff --git a/tests/unit/servers/conftest.py b/tests/unit/servers/conftest.py index d1c304e..0164932 100644 --- a/tests/unit/servers/conftest.py +++ b/tests/unit/servers/conftest.py @@ -338,6 +338,54 @@ def response_update_server(): } +@pytest.fixture() +def response_get_metrics(): + return { + "metrics": { + "start": "2023-12-14T17:40:00+01:00", + "end": "2023-12-14T17:50:00+01:00", + "step": 3.0, + "time_series": { + "cpu": { + "values": [ + [1702572594, "0.3746000025854892"], + [1702572597, "0.35842215349409734"], + [1702572600, "0.7381525488039541"], + ] + }, + "disk.0.iops.read": { + "values": [ + [1702572594, "0"], + [1702572597, "0"], + [1702572600, "0"], + ] + }, + "disk.0.bandwidth.read": { + "values": [ + [1702572594, "0"], + [1702572597, "0"], + [1702572600, "0"], + ] + }, + "disk.0.bandwidth.write": { + "values": [ + [1702572594, "24064"], + [1702572597, "2048"], + [1702572600, "0"], + ] + }, + "disk.0.iops.write": { + "values": [ + [1702572594, "4.875"], + [1702572597, "0.25"], + [1702572600, "0"], + ] + }, + }, + } + } + + @pytest.fixture() def response_simple_servers(): return { diff --git a/tests/unit/servers/test_client.py b/tests/unit/servers/test_client.py index a8ba355..70823c3 100644 --- a/tests/unit/servers/test_client.py +++ b/tests/unit/servers/test_client.py @@ -190,6 +190,32 @@ def test_delete(self, hetzner_client, bound_server, generic_action): assert action.id == 1 assert action.progress == 0 + def test_get_metrics( + self, + hetzner_client, + bound_server: BoundServer, + response_get_metrics, + ): + hetzner_client.request.return_value = response_get_metrics + response = bound_server.get_metrics( + type=["cpu", "disk"], + start="2023-12-14T17:40:00+01:00", + end="2023-12-14T17:50:00+01:00", + ) + hetzner_client.request.assert_called_with( + url="/servers/14/metrics", + method="GET", + params={ + "type": "cpu,disk", + "start": "2023-12-14T17:40:00+01:00", + "end": "2023-12-14T17:50:00+01:00", + }, + ) + + assert "cpu" in response.metrics.time_series + assert "disk.0.iops.read" in response.metrics.time_series + assert len(response.metrics.time_series["disk.0.iops.read"]["values"]) == 3 + def test_power_off(self, hetzner_client, bound_server, generic_action): hetzner_client.request.return_value = generic_action action = bound_server.power_off()