diff --git a/linode_api4/groups/monitor.py b/linode_api4/groups/monitor.py index 2dbfd2285..3be4f69b8 100644 --- a/linode_api4/groups/monitor.py +++ b/linode_api4/groups/monitor.py @@ -1,18 +1,21 @@ -__all__ = [ - "MonitorGroup", -] -from typing import Any, Optional +from typing import Any, Optional, Union from linode_api4 import PaginatedList from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group from linode_api4.objects import ( + AlertChannel, + AlertDefinition, MonitorDashboard, MonitorMetricsDefinition, MonitorService, MonitorServiceToken, ) +__all__ = [ + "MonitorGroup", +] + class MonitorGroup(Group): """ @@ -145,3 +148,139 @@ def create_token( "Unexpected response when creating token!", json=result ) return MonitorServiceToken(token=result["token"]) + + def alert_definitions( + self, + *filters, + service_type: Optional[str] = None, + ) -> Union[PaginatedList]: + """ + Retrieve alert definitions. + + Returns a paginated collection of :class:`AlertDefinition` objects. If you + need to obtain a single :class:`AlertDefinition`, use :meth:`LinodeClient.load` + and supply the `service_type` as the parent identifier, for example: + + alerts = client.monitor.alert_definitions() + alerts_by_service = client.monitor.alert_definitions(service_type="dbaas") + .. note:: This endpoint is in beta and requires using the v4beta base URL. + + API Documentation: + https://techdocs.akamai.com/linode-api/reference/get-alert-definitions + https://techdocs.akamai.com/linode-api/reference/get-alert-definitions-for-service-type + + :param service_type: Optional service type to scope the query (e.g. ``"dbaas"``). + :type service_type: Optional[str] + :param filters: Optional filtering expressions to apply to the returned + collection. See :doc:`Filtering Collections`. + + :returns: A paginated list of :class:`AlertDefinition` objects. + :rtype: PaginatedList[AlertDefinition] + """ + + endpoint = "/monitor/alert-definitions" + if service_type: + endpoint = f"/monitor/services/{service_type}/alert-definitions" + + # Requesting a list + return self.client._get_and_filter( + AlertDefinition, *filters, endpoint=endpoint + ) + + def alert_channels(self, *filters) -> PaginatedList: + """ + List alert channels for the authenticated account. + + Returns a paginated collection of :class:`AlertChannel` objects which + describe destinations for alert notifications (for example: email + lists, webhooks, PagerDuty, Slack, etc.). By default this method + returns all channels visible to the authenticated account; you can + supply optional filter expressions to restrict the results. + + Examples: + channels = client.monitor.alert_channels() + + .. note:: This endpoint is in beta and requires using the v4beta base URL. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-alert-channels + + :param filters: Optional filter expressions to apply to the collection. + See :doc:`Filtering Collections` for details. + :returns: A paginated list of :class:`AlertChannel` objects. + :rtype: PaginatedList[AlertChannel] + """ + return self.client._get_and_filter(AlertChannel, *filters) + + def create_alert_definition( + self, + service_type: str, + label: str, + severity: int, + channel_ids: list[int], + rule_criteria: dict = None, + trigger_conditions: dict = None, + entity_ids: Optional[list[str]] = None, + description: Optional[str] = None, + ) -> AlertDefinition: + """ + Create a new alert definition for a given service type. + + The alert definition configures when alerts are fired and which channels + are notified. + + .. note:: This endpoint is in beta and requires using the v4beta base URL. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-alert-definition-for-service-type + + :param service_type: Service type for which to create the alert definition + (e.g. ``"dbaas"``). + :type service_type: str + :param label: Human-readable label for the alert definition. + :type label: str + :param severity: Severity level for the alert (numeric severity used by API). + :type severity: int + :param channel_ids: List of alert channel IDs to notify when the alert fires. + :type channel_ids: list[int] + :param rule_criteria: (Optional) Rule criteria that determine when the alert + should be evaluated. Structure depends on the service + metric definitions. + :type rule_criteria: Optional[dict] + :param trigger_conditions: (Optional) Trigger conditions that define when + the alert should transition state. + :type trigger_conditions: Optional[dict] + :param entity_ids: (Optional) Restrict the alert to a subset of entity IDs. + :type entity_ids: Optional[list[str]] + :param description: (Optional) Longer description for the alert definition. + :type description: Optional[str] + + :returns: The newly created :class:`AlertDefinition`. + :rtype: AlertDefinition + + NOTE: + # For updating an alert definition, use the `save()` method on the AlertDefinition object. + # For deleting an alert definition, use the `delete()` method directly on the AlertDefinition object. + """ + params = { + "label": label, + "severity": severity, + "channel_ids": channel_ids, + "rule_criteria": rule_criteria, + "trigger_conditions": trigger_conditions, + } + if description is not None: + params["description"] = description + if entity_ids is not None: + params["entity_ids"] = entity_ids + + # API will handle check for service_type return error if missing + result = self.client.post( + f"/monitor/services/{service_type}/alert-definitions", data=params + ) + + if "id" not in result: + raise UnexpectedResponseError( + "Unexpected response when creating alert definition!", + json=result, + ) + + return AlertDefinition(self.client, result["id"], service_type, result) diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index ed6ce79a5..e9cad5227 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -1,15 +1,23 @@ +from dataclasses import dataclass, field +from typing import Any, List, Optional, Union + +from linode_api4.objects import DerivedBase +from linode_api4.objects.base import Base, Property +from linode_api4.objects.serializable import JSONObject, StrEnum + __all__ = [ + "AlertType", "MonitorDashboard", "MonitorMetricsDefinition", "MonitorService", "MonitorServiceToken", "AggregateFunction", + "RuleCriteria", + "TriggerConditions", + "AlertChannel", + "AlertDefinition", + "AlertChannelEnvelope", ] -from dataclasses import dataclass, field -from typing import List, Optional - -from linode_api4.objects.base import Base, Property -from linode_api4.objects.serializable import JSONObject, StrEnum class AggregateFunction(StrEnum): @@ -62,6 +70,15 @@ class MetricType(StrEnum): summary = "summary" +class CriteriaCondition(StrEnum): + """ + Enum for supported CriteriaCondition + Currently, only ALL is supported. + """ + + all = "ALL" + + class MetricUnit(StrEnum): """ Enum for supported metric units. @@ -183,3 +200,209 @@ class MonitorServiceToken(JSONObject): """ token: str = "" + + +@dataclass +class TriggerConditions(JSONObject): + """ + Represents the trigger/evaluation configuration for an alert. + + Expected JSON example: + "trigger_conditions": { + "criteria_condition": "ALL", + "evaluation_period_seconds": 60, + "polling_interval_seconds": 10, + "trigger_occurrences": 3 + } + + Fields: + - criteria_condition: "ALL" (currently, only "ALL" is supported) + - evaluation_period_seconds: seconds over which the rule(s) are evaluated + - polling_interval_seconds: how often metrics are sampled (seconds) + - trigger_occurrences: how many consecutive evaluation periods must match to trigger + """ + + criteria_condition: CriteriaCondition = CriteriaCondition.all + evaluation_period_seconds: int = 0 + polling_interval_seconds: int = 0 + trigger_occurrences: int = 0 + + +@dataclass +class DimensionFilter(JSONObject): + """ + A single dimension filter used inside a Rule. + + Example JSON: + { + "dimension_label": "node_type", + "label": "Node Type", + "operator": "eq", + "value": "primary" + } + """ + + dimension_label: str = "" + label: str = "" + operator: str = "" + value: Optional[str] = None + + +@dataclass +class Rule(JSONObject): + """ + A single rule within RuleCriteria. + Example JSON: + { + "aggregate_function": "avg", + "dimension_filters": [ ... ], + "label": "Memory Usage", + "metric": "memory_usage", + "operator": "gt", + "threshold": 95, + "unit": "percent" + } + """ + + aggregate_function: Union[AggregateFunction, str] = "" + dimension_filters: Optional[List[DimensionFilter]] = None + label: str = "" + metric: str = "" + operator: str = "" + threshold: Optional[float] = None + unit: Optional[str] = None + + +@dataclass +class RuleCriteria(JSONObject): + """ + Container for a list of Rule objects, matching the JSON shape: + "rule_criteria": { "rules": [ { ... }, ... ] } + """ + + rules: List[Rule] = None + + +@dataclass +class AlertChannelEnvelope(JSONObject): + """ + Represents a single alert channel entry returned inside alert definition + responses. + + This envelope type is used when an AlertDefinition includes a list of + alert channels. It contains lightweight information about the channel so + that callers can display or reference the channel without performing an + additional API lookup. + + Fields: + - id: int - Unique identifier of the alert channel. + - label: str - Human-readable name for the channel. + - type: str - Channel type (e.g. 'webhook', 'email', 'pagerduty'). + - url: str - Destination URL or address associated with the channel. + """ + + id: int = 0 + label: str = "" + type: str = "" + url: str = "" + + +class AlertType(StrEnum): + """ + Enumeration of alert origin types used by alert definitions. + + Values: + - SYSTEM: Alerts that originate from the system (built-in or platform-managed). + - USER: Alerts created and managed by users (custom alerts). + + The API uses this value in the `type` field of alert-definition responses. + This enum can be used to compare or validate the `type` value when + processing alert definitions. + """ + + system = "system" + user = "user" + + +class AlertDefinition(DerivedBase): + """ + Represents an alert definition for a monitor service. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-alert-definition + """ + + api_endpoint = "/monitor/services/{service_type}/alert-definitions/{id}" + derived_url_path = "alert-definitions" + parent_id_name = "service_type" + id_attribute = "id" + + properties = { + "id": Property(identifier=True), + "service_type": Property(identifier=True), + "label": Property(mutable=True), + "severity": Property(mutable=True), + "type": Property(AlertType), + "status": Property(), + "has_more_resources": Property(), + "rule_criteria": Property(RuleCriteria), + "trigger_conditions": Property(TriggerConditions), + "alert_channels": Property(List[AlertChannelEnvelope]), + "created": Property(is_datetime=True), + "updated": Property(is_datetime=True), + "updated_by": Property(), + "created_by": Property(), + "entity_ids": Property(List[Any]), + "description": Property(mutable=True), + "_class": Property("class"), + } + + +@dataclass +class EmailChannelContent(JSONObject): + """ + Represents the content for an email alert channel. + """ + + email_addresses: Optional[List[str]] = None + + +@dataclass +class ChannelContent(JSONObject): + """ + Represents the content block for an AlertChannel, which varies by channel type. + """ + + email: Optional[EmailChannelContent] = None + # Other channel types like 'webhook', 'slack' could be added here as Optional fields. + + +class AlertChannel(Base): + """ + Represents an alert channel used to deliver notifications when alerts + fire. Alert channels define a destination and configuration for + notifications (for example: email lists, webhooks, PagerDuty, Slack, etc.). + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-alert-channels + + This class maps to the Monitor API's `/monitor/alert-channels` resource + and is used by the SDK to list, load, and inspect channels. + + NOTE: Only read operations are supported for AlertChannel at this time. + Create, update, and delete (CRUD) operations are not allowed. + """ + + api_endpoint = "/monitor/alert-channels/{id}" + + properties = { + "id": Property(identifier=True), + "label": Property(), + "type": Property("channel_type"), + "channel_type": Property(), + "content": Property(ChannelContent), + "created": Property(is_datetime=True), + "updated": Property(is_datetime=True), + "created_by": Property(), + "updated_by": Property(), + "url": Property(), + # Add other fields as needed + } diff --git a/linode_api4/objects/serializable.py b/linode_api4/objects/serializable.py index c1f59f6d4..3df36be90 100644 --- a/linode_api4/objects/serializable.py +++ b/linode_api4/objects/serializable.py @@ -183,6 +183,7 @@ def attempt_serialize(value: Any) -> Any: """ Attempts to serialize the given value, else returns the value unchanged. """ + # Handle JSONObject instances recursively if issubclass(type(value), JSONObject): return value._serialize(is_put=is_put) diff --git a/test/fixtures/monitor_alert-definitions.json b/test/fixtures/monitor_alert-definitions.json new file mode 100644 index 000000000..267b96120 --- /dev/null +++ b/test/fixtures/monitor_alert-definitions.json @@ -0,0 +1,27 @@ +{ + "data": [ + { + "id": 12345, + "label": "Test Alert for DBAAS", + "service_type": "dbaas", + "severity": 1, + "type": "user", + "description": "A test alert for dbaas service", + "conditions": [], + "entity_ids": [13217], + "alert_channels": [], + "has_more_resources": false, + "rule_criteria": null, + "trigger_conditions": null, + "class": "alert", + "notification_groups": [], + "status": "active", + "created": "2024-01-01T00:00:00", + "updated": "2024-01-01T00:00:00", + "updated_by": "tester" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/monitor_services_dbaas_alert-definitions.json b/test/fixtures/monitor_services_dbaas_alert-definitions.json new file mode 100644 index 000000000..0c7067a8a --- /dev/null +++ b/test/fixtures/monitor_services_dbaas_alert-definitions.json @@ -0,0 +1,52 @@ +{ + "data": [ + { + "id": 12345, + "label": "Test Alert for DBAAS", + "service_type": "dbaas", + "severity": 1, + "type": "user", + "description": "A test alert for dbaas service", + "entity_ids": [ + "13217" + ], + "alert_channels": [], + "has_more_resources": false, + "rule_criteria": { + "rules": [ + { + "aggregate_function": "avg", + "dimension_filters": [ + { + "dimension_label": "node_type", + "label": "Node Type", + "operator": "eq", + "value": "primary" + } + ], + "label": "High CPU Usage", + "metric": "cpu_usage", + "operator": "gt", + "threshold": 90, + "unit": "percent" + } + ] + }, + "trigger_conditions": { + "criteria_condition": "ALL", + "evaluation_period_seconds": 300, + "polling_interval_seconds": 60, + "trigger_occurrences": 3 + }, + "class": "alert", + "notification_groups": [], + "status": "active", + "created": "2024-01-01T00:00:00", + "updated": "2024-01-01T00:00:00", + "updated_by": "tester" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json b/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json new file mode 100644 index 000000000..822e18b24 --- /dev/null +++ b/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json @@ -0,0 +1,44 @@ +{ + "id": 12345, + "label": "Test Alert for DBAAS", + "service_type": "dbaas", + "severity": 1, + "type": "user", + "description": "A test alert for dbaas service", + "entity_ids": [ + "13217" + ], + "alert_channels": [], + "has_more_resources": false, + "rule_criteria": { + "rules": [ + { + "aggregate_function": "avg", + "dimension_filters": [ + { + "dimension_label": "node_type", + "label": "Node Type", + "operator": "eq", + "value": "primary" + } + ], + "label": "High CPU Usage", + "metric": "cpu_usage", + "operator": "gt", + "threshold": 90, + "unit": "percent" + } + ] + }, + "trigger_conditions": { + "criteria_condition": "ALL", + "evaluation_period_seconds": 300, + "polling_interval_seconds": 60, + "trigger_occurrences": 3 + }, + "class": "alert", + "status": "active", + "created": "2024-01-01T00:00:00", + "updated": "2024-01-01T00:00:00", + "updated_by": "tester" +} diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 3692269dc..caac7ca01 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -34,6 +34,7 @@ ENV_REGION_OVERRIDE = "LINODE_TEST_REGION_OVERRIDE" ENV_API_CA_NAME = "LINODE_API_CA" RUN_LONG_TESTS = "RUN_LONG_TESTS" +SKIP_E2E_FIREWALL = "SKIP_E2E_FIREWALL" def get_token(): @@ -85,6 +86,12 @@ def run_long_tests(): @pytest.fixture(autouse=True, scope="session") def e2e_test_firewall(test_linode_client): + # Allow skipping firewall creation for local runs: set SKIP_E2E_FIREWALL=1 + if os.environ.get(SKIP_E2E_FIREWALL): + # Yield None so fixtures depending on this receive a falsy value but the session continues. + yield None + return + def is_valid_ipv4(address): try: ipaddress.IPv4Address(address) diff --git a/test/integration/models/monitor/test_monitor.py b/test/integration/models/monitor/test_monitor.py index b458fd399..00a201dd6 100644 --- a/test/integration/models/monitor/test_monitor.py +++ b/test/integration/models/monitor/test_monitor.py @@ -1,3 +1,4 @@ +import time from test.integration.helpers import ( get_test_label, send_request_when_resource_available, @@ -6,8 +7,9 @@ import pytest -from linode_api4 import LinodeClient +from linode_api4 import ApiError, LinodeClient from linode_api4.objects import ( + AlertDefinition, MonitorDashboard, MonitorMetricsDefinition, MonitorService, @@ -112,3 +114,128 @@ def test_my_db_functionality(test_linode_client, test_create_and_test_db): assert isinstance(token, MonitorServiceToken) assert len(token.token) > 0, "Token should not be empty" assert hasattr(token, "token"), "Response object has no 'token' attribute" + + +def test_integration_create_get_update_delete_alert_definition( + test_linode_client, +): + """E2E: create an alert definition, fetch it, update it, then delete it. + + This test attempts to be resilient: it cleans up the created definition + in a finally block so CI doesn't leak resources. + """ + client = test_linode_client + service_type = "dbaas" + label = get_test_label() + "-e2e-alert" + + rule_criteria = { + "rules": [ + { + "aggregate_function": "avg", + "dimension_filters": [ + { + "dimension_label": "node_type", + "label": "Node Type", + "operator": "eq", + "value": "primary", + } + ], + "label": "Memory Usage", + "metric": "memory_usage", + "operator": "gt", + "threshold": 90, + "unit": "percent", + } # <-- Close the rule dictionary here + ] + } + trigger_conditions = { + "criteria_condition": "ALL", + "evaluation_period_seconds": 300, + "polling_interval_seconds": 300, + "trigger_occurrences": 1, + } + + # Make the label unique and ensure it begins/ends with an alphanumeric char + label = f"{label}-{int(time.time())}" + description = "E2E alert created by SDK integration test" + + # Pick an existing alert channel to attach to the definition; skip if none + channels = list(client.monitor.alert_channels()) + if not channels: + pytest.skip( + "No alert channels available on account for creating alert definitions" + ) + + created = None + try: + # Create the alert definition using API-compliant top-level fields + created = client.monitor.create_alert_definition( + service_type=service_type, + label=label, + severity=1, + description=description, + channel_ids=[channels[0].id], + rule_criteria=rule_criteria, + trigger_conditions=trigger_conditions, + ) + + assert created.id + assert getattr(created, "label", None) == label + + # Wait for server-side processing to complete (status transitions) + timeout = 120 + interval = 10 + start = time.time() + while ( + getattr(created, "status", None) == "in progress" + and (time.time() - start) < timeout + ): + time.sleep(interval) + try: + created = client.load(AlertDefinition, created.id, service_type) + except Exception: + # transient errors while polling; continue until timeout + pass + + update_alert = client.load(AlertDefinition, created.id, service_type) + update_alert.label = f"{label}-updated" + update_alert.save() + + updated = client.load(AlertDefinition, update_alert.id, service_type) + while ( + getattr(updated, "status", None) == "in progress" + and (time.time() - start) < timeout + ): + time.sleep(interval) + try: + updated = client.load(AlertDefinition, updated.id, service_type) + except Exception: + # transient errors while polling; continue until timeout + pass + + assert created.id == updated.id + assert updated.label == f"{label}-updated" + + finally: + if created: + # Best-effort cleanup; allow transient errors. + # max time alert should take to update + try: + delete_alert = client.load( + AlertDefinition, created.id, service_type + ) + delete_alert.delete() + except Exception: + pass + + # confirm it's gone (if API returns 404 or raises) + try: + client.load(AlertDefinition, created.id, service_type) + # If no exception, fail explicitly + assert False, "Alert definition still retrievable after delete" + except ApiError: + # Expected: alert definition is deleted and API returns 404 or similar error + pass + except Exception: + # Any other exception is acceptable here, as the resource should be gone + pass diff --git a/test/unit/groups/monitor_api_test.py b/test/unit/groups/monitor_api_test.py index c34db068f..9515895ae 100644 --- a/test/unit/groups/monitor_api_test.py +++ b/test/unit/groups/monitor_api_test.py @@ -1,6 +1,11 @@ -from test.unit.base import MonitorClientBaseCase +from test.unit.base import ClientBaseCase, MonitorClientBaseCase -from linode_api4.objects import AggregateFunction, EntityMetricOptions +from linode_api4 import PaginatedList +from linode_api4.objects import ( + AggregateFunction, + AlertDefinition, + EntityMetricOptions, +) class MonitorAPITest(MonitorClientBaseCase): @@ -11,7 +16,7 @@ class MonitorAPITest(MonitorClientBaseCase): def test_fetch_metrics(self): service_type = "dbaas" url = f"/monitor/services/{service_type}/metrics" - with self.mock_post(url) as m: + with self.mock_post(url) as mock_post: metrics = self.client.metrics.fetch_metrics( service_type, entity_ids=[13217, 13316], @@ -26,8 +31,8 @@ def test_fetch_metrics(self): ) # assert call data - assert m.call_url == url - assert m.call_data == { + assert mock_post.call_url == url + assert mock_post.call_data == { "entity_ids": [13217, 13316], "metrics": [ {"name": "avg_read_iops", "aggregate_function": "avg"}, @@ -50,3 +55,64 @@ def test_fetch_metrics(self): assert metrics.stats.executionTimeMsec == 21 assert metrics.stats.seriesFetched == "2" assert not metrics.isPartial + + +class MonitorAlertDefinitionsTest(ClientBaseCase): + def test_alert_definition(self): + service_type = "dbaas" + url = f"/monitor/services/{service_type}/alert-definitions" + with self.mock_get(url) as mock_get: + alert = self.client.monitor.alert_definitions( + service_type=service_type + ) + + assert mock_get.call_url == url + + # assert collection and element types + assert isinstance(alert, PaginatedList) + assert isinstance(alert[0], AlertDefinition) + + # fetch the raw JSON from the client and assert its fields + raw = self.client.get(url) + # raw is a paginated response; check first item's fields + first = raw["data"][0] + assert first["label"] == "Test Alert for DBAAS" + assert first["service_type"] == "dbaas" + assert first["status"] == "active" + assert first["created"] == "2024-01-01T00:00:00" + + def test_create_alert_definition(self): + service_type = "dbaas" + url = f"/monitor/services/{service_type}/alert-definitions" + result = { + "id": 67890, + "label": "Created Alert", + "service_type": service_type, + "severity": 1, + "status": "active", + } + + with self.mock_post(result) as mock_post: + alert = self.client.monitor.create_alert_definition( + service_type=service_type, + label="Created Alert", + severity=1, + channel_ids=[1, 2], + rule_criteria={"rules": []}, + trigger_conditions={"criteria_condition": "ALL"}, + entity_ids=["13217"], + description="created via test", + ) + + assert mock_post.call_url == url + # payload should include the provided fields + assert mock_post.call_data["label"] == "Created Alert" + assert mock_post.call_data["severity"] == 1 + assert "channel_ids" in mock_post.call_data + + assert isinstance(alert, AlertDefinition) + assert alert.id == 67890 + + # fetch the same response from the client and assert + resp = self.client.post(url, data={}) + assert resp["label"] == "Created Alert"