From e2f4a093a923ceaccc7a4e4b9ed9a19b3a8e569d Mon Sep 17 00:00:00 2001 From: shkaruna Date: Fri, 5 Jun 2026 10:28:05 +0530 Subject: [PATCH] feat: add aclp clone API and group_by field --- linode_api4/groups/monitor.py | 95 +++++++++++ linode_api4/objects/monitor.py | 1 + test/fixtures/monitor_alert-definitions.json | 3 + ...itor_services_dbaas_alert-definitions.json | 3 + ...ervices_dbaas_alert-definitions_12345.json | 3 + ...s_dbaas_alert-definitions_12345_clone.json | 57 +++++++ .../models/monitor/test_monitor.py | 160 +++++++++++++++--- test/unit/groups/monitor_api_test.py | 76 +++++++++ 8 files changed, 375 insertions(+), 23 deletions(-) create mode 100644 test/fixtures/monitor_services_dbaas_alert-definitions_12345_clone.json diff --git a/linode_api4/groups/monitor.py b/linode_api4/groups/monitor.py index 08170c8d7..637cf706d 100644 --- a/linode_api4/groups/monitor.py +++ b/linode_api4/groups/monitor.py @@ -235,6 +235,7 @@ def create_alert_definition( description: Optional[str] = None, scope: Optional[Union[AlertScope, str]] = None, regions: Optional[list[str]] = None, + group_by: Optional[list[str]] = None, ) -> AlertDefinition: """ Create a new alert definition for a given service type. @@ -270,6 +271,8 @@ def create_alert_definition( :type scope: Optional[Union[AlertScope, str]] :param regions: (Optional) Regions to monitor. :type regions: Optional[list[str]] + :param group_by: (Optional) Aggregates metric data by dimension so that alert conditions are evaluated independently for each dimension value. + :type group_by: Optional[list[str]] :returns: The newly created :class:`AlertDefinition`. :rtype: AlertDefinition @@ -294,6 +297,8 @@ def create_alert_definition( params["scope"] = scope if regions is not None: params["regions"] = regions + if group_by is not None: + params["group_by"] = group_by # API will validate service_type and return an error if missing result = self.client.post( @@ -308,6 +313,96 @@ def create_alert_definition( return AlertDefinition(self.client, result["id"], service_type, result) + def clone_alert_definition( + self, + service_type: str, + id: int, + label: str, + description: Optional[str] = None, + scope: Optional[Union[AlertScope, str]] = None, + regions: Optional[list[str]] = None, + entity_ids: Optional[list[str]] = None, + severity: Optional[int] = None, + rule_criteria: Optional[dict] = None, + trigger_conditions: Optional[dict] = None, + channel_ids: Optional[list[int]] = None, + group_by: Optional[list[str]] = None, + ) -> AlertDefinition: + """ + Clone an existing alert definition for a given service type. + The clone request creates a new alert definition based on the source + definition identified by ``id``. + + API URL: POST /monitor/services/{service_type}/alert-definitions/{id}/clone + API Documentation: TODO + + :param service_type: Service type for the source alert definition + (e.g. ``"dbaas"``). + :type service_type: str + :param id: Source alert definition identifier. + :type id: int (Alert identifier) + :param label: Human-readable label for the cloned alert definition. + This value is mandatory and must be unique. + :type label: str + :param description: (Optional) Longer description for the cloned alert definition. + :type description: Optional[str] + :param scope: (Optional) Alert scope provided in the clone request. + Scope is inherited from the source alert and is immutable. + :type scope: Optional[Union[AlertScope, str]] + :param regions: (Optional) Regions to monitor. + :type regions: Optional[list[str]] + :param entity_ids: (Optional) Restrict the alert to a subset of entity IDs. + :type entity_ids: Optional[list[str]] + :param severity: (Optional) Severity level for the alert. + :type severity: Optional[int] + :param rule_criteria: (Optional) Rule criteria used to evaluate the alert. + :type rule_criteria: Optional[dict] + :param trigger_conditions: (Optional) Trigger conditions for alert state transitions. + :type trigger_conditions: Optional[dict] + :param channel_ids: (Optional) List of alert channel IDs to notify. + :type channel_ids: Optional[list[int]] + :param group_by: (Optional) Aggregates metric data by dimension so that alert conditions are evaluated independently for each dimension value. + :type group_by: Optional[list[str]] + + :returns: The newly created cloned :class:`AlertDefinition`. + :rtype: AlertDefinition + """ + params = { + "label": label, + } + + if description is not None: + params["description"] = description + if scope is not None: + params["scope"] = scope + if regions is not None: + params["regions"] = regions + if entity_ids is not None: + params["entity_ids"] = entity_ids + if severity is not None: + params["severity"] = severity + if rule_criteria is not None: + params["rule_criteria"] = rule_criteria + if trigger_conditions is not None: + params["trigger_conditions"] = trigger_conditions + if channel_ids is not None: + params["channel_ids"] = channel_ids + if group_by is not None: + params["group_by"] = group_by + + result = self.client.post( + f"/monitor/services/{service_type}/alert-definitions/{id}/clone", + data=params, + ) + + if "id" not in result: + raise UnexpectedResponseError( + "Unexpected response when cloning alert definition!", + json=result, + ) + + return AlertDefinition(self.client, result["id"], service_type, result) + def alert_definition_entities( self, service_type: str, diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index c5f751fde..dece557c4 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -498,6 +498,7 @@ class AlertDefinition(DerivedBase): "regions": Property(mutable=True), "entities": Property(json_object=AlertEntities), "channel_ids": Property(mutable=True), + "group_by": Property(mutable=True), } diff --git a/test/fixtures/monitor_alert-definitions.json b/test/fixtures/monitor_alert-definitions.json index 2e040605f..dfa922f9e 100644 --- a/test/fixtures/monitor_alert-definitions.json +++ b/test/fixtures/monitor_alert-definitions.json @@ -9,6 +9,9 @@ "description": "A test alert for dbaas service", "scope": "entity", "regions": [], + "group_by": [ + "entity_id" + ], "entities": { "url": "/monitor/services/dbaas/alert-definitions/12345/entities", "count": 1, diff --git a/test/fixtures/monitor_services_dbaas_alert-definitions.json b/test/fixtures/monitor_services_dbaas_alert-definitions.json index 67ea9d2ab..2213d89d5 100644 --- a/test/fixtures/monitor_services_dbaas_alert-definitions.json +++ b/test/fixtures/monitor_services_dbaas_alert-definitions.json @@ -9,6 +9,9 @@ "description": "A test alert for dbaas service", "scope": "entity", "regions": [], + "group_by": [ + "entity_id" + ], "entities": { "url": "/monitor/services/dbaas/alert-definitions/12345/entities", "count": 1, diff --git a/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json b/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json index 4d70f66b1..1db4db853 100644 --- a/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json +++ b/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json @@ -7,6 +7,9 @@ "description": "A test alert for dbaas service", "scope": "entity", "regions": [], + "group_by": [ + "entity_id" + ], "entities": { "url": "/monitor/services/dbaas/alert-definitions/12345/entities", "count": 1, diff --git a/test/fixtures/monitor_services_dbaas_alert-definitions_12345_clone.json b/test/fixtures/monitor_services_dbaas_alert-definitions_12345_clone.json new file mode 100644 index 000000000..36e7fa9fc --- /dev/null +++ b/test/fixtures/monitor_services_dbaas_alert-definitions_12345_clone.json @@ -0,0 +1,57 @@ +{ + "id": 67891, + "label": "Cloned Alert", + "service_type": "dbaas", + "severity": 1, + "type": "user", + "description": "Cloned from source alert", + "scope": "entity", + "regions": [], + "group_by": [ + "entity_id" + ], + "entities": { + "url": "/monitor/services/dbaas/alert-definitions/67891/entities", + "count": 1, + "has_more_resources": false + }, + "alert_channels": [ + { + "id": 10000, + "label": "Read-Write Channel", + "type": "email", + "url": "/monitor/alert-channels/10000" + } + ], + "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/models/monitor/test_monitor.py b/test/integration/models/monitor/test_monitor.py index ceb9fdc3a..7eefef3d4 100644 --- a/test/integration/models/monitor/test_monitor.py +++ b/test/integration/models/monitor/test_monitor.py @@ -20,6 +20,33 @@ from linode_api4.objects.monitor import AlertStatus +def wait_for_alert_ready( + client: LinodeClient, + alert_id: int, + service_type: str, + timeout: int = 360, + initial_timeout: int = 1, +) -> AlertDefinition: + """Wait for an alert definition to reach enabled status, with backoff.""" + start = time.time() + interval = initial_timeout + alert = client.load(AlertDefinition, alert_id, service_type) + while ( + getattr(alert, "status", None) + != AlertStatus.AlertDefinitionStatusEnabled + and (time.time() - start) < timeout + ): + time.sleep(interval) + interval *= 2 + try: + alert._api_get() + except ApiError as e: + # transient errors while polling; continue until timeout + if e.status != 404: + raise + return alert + + # List all dashboards def test_get_all_dashboards(test_linode_client): client = test_linode_client @@ -222,27 +249,6 @@ def test_integration_create_get_update_delete_alert_definition( created = None - def wait_for_alert_ready(alert_id, service_type: str): - timeout = 360 # maximum time in seconds to wait for alert creation - initial_timeout = 1 - start = time.time() - interval = initial_timeout - alert = client.load(AlertDefinition, alert_id, service_type) - while ( - getattr(alert, "status", None) - != AlertStatus.AlertDefinitionStatusEnabled - and (time.time() - start) < timeout - ): - time.sleep(interval) - interval *= 2 - try: - alert._api_get() - except ApiError as e: - # transient errors while polling; continue until timeout - if e.status != 404: - raise - return alert - try: # Create the alert definition using API-compliant top-level fields created = client.monitor.create_alert_definition( @@ -258,18 +264,20 @@ def wait_for_alert_ready(alert_id, service_type: str): assert created.id assert getattr(created, "label", None) == label assert getattr(created, "entities", None) is not None + assert isinstance(created.group_by, list) - created = wait_for_alert_ready(created.id, service_type) + created = wait_for_alert_ready(client, created.id, service_type) updated = client.load(AlertDefinition, created.id, service_type) updated.label = f"{label}-updated" updated.save() assert getattr(updated, "entities", None) is not None - updated = wait_for_alert_ready(updated.id, service_type) + updated = wait_for_alert_ready(client, updated.id, service_type) assert created.id == updated.id assert updated.label == f"{label}-updated" + assert isinstance(updated.group_by, list) finally: if created: @@ -297,6 +305,7 @@ def test_alert_definition_entities(test_linode_client): pytest.fail("No alert definitions available for dbaas service type") assert getattr(alert_definitions[0], "entities", None) is not None + assert isinstance(alert_definitions[0].group_by, list) alert_def = alert_definitions[0] entities = client.monitor.alert_definition_entities( @@ -311,3 +320,108 @@ def test_alert_definition_entities(test_linode_client): assert entity.label assert entity.url assert entity._type == service_type + + +def test_integration_clone_alert_definition(test_linode_client): + """E2E: create a source alert definition, clone it, then delete both.""" + client = test_linode_client + service_type = "dbaas" + source_label = f"{get_test_label()}-e2e-source-alert-{int(time.time())}" + clone_label = f"{get_test_label()}-e2e-cloned-alert-{int(time.time())}" + + 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", + } + ] + } + trigger_conditions = { + "criteria_condition": "ALL", + "evaluation_period_seconds": 300, + "polling_interval_seconds": 300, + "trigger_occurrences": 1, + } + + channels = list(client.monitor.alert_channels()) + if not channels: + pytest.skip( + "No alert channels available on account for creating/cloning alert definitions" + ) + + created = None + cloned_alert = None + + try: + # Create the source alert definition + created = client.monitor.create_alert_definition( + service_type=service_type, + label=source_label, + severity=1, + channel_ids=[channels[0].id], + rule_criteria=rule_criteria, + trigger_conditions=trigger_conditions, + ) + + created = wait_for_alert_ready(client, created.id, service_type) + + clone_trigger_conditions_overrides = { + "criteria_condition": "ALL", + "evaluation_period_seconds": 900, + "polling_interval_seconds": 300, + "trigger_occurrences": 3, + } + + # Clone the alert definition, overriding the label and trigger conditions + cloned_alert = client.monitor.clone_alert_definition( + service_type=service_type, + id=created.id, + label=clone_label, + trigger_conditions=clone_trigger_conditions_overrides, + ) + + cloned_alert = wait_for_alert_ready( + client, cloned_alert.id, service_type + ) + + assert created.id + assert cloned_alert.id + assert cloned_alert.id != created.id + assert cloned_alert.label == clone_label + assert cloned_alert.label != created.label + assert cloned_alert.scope == created.scope + assert cloned_alert.rule_criteria is not None + assert created.rule_criteria is not None + assert cloned_alert.rule_criteria.dict == created.rule_criteria.dict + assert created.trigger_conditions is not None + assert cloned_alert.trigger_conditions is not None + assert ( + cloned_alert.trigger_conditions.dict + != created.trigger_conditions.dict + ) + + finally: + if cloned_alert: + delete_clone_alert = client.load( + AlertDefinition, cloned_alert.id, service_type + ) + delete_clone_alert.delete() + + if created: + delete_source_alert = client.load( + AlertDefinition, created.id, service_type + ) + delete_source_alert.delete() diff --git a/test/unit/groups/monitor_api_test.py b/test/unit/groups/monitor_api_test.py index fdc93060c..ddf5ecdee 100644 --- a/test/unit/groups/monitor_api_test.py +++ b/test/unit/groups/monitor_api_test.py @@ -75,6 +75,7 @@ def test_alert_definition(self): assert isinstance(alert[0], AlertDefinition) assert alert[0].scope == "entity" assert alert[0].regions == [] + assert alert[0].group_by == ["entity_id"] assert alert[0].entities.url.endswith( "/alert-definitions/12345/entities" ) @@ -95,6 +96,7 @@ def test_alert_definition(self): assert first["label"] == "Test Alert for DBAAS" assert first["service_type"] == "dbaas" assert first["status"] == "active" + assert first["group_by"] == ["entity_id"] assert first["created"] == "2024-01-01T00:00:00" def test_create_alert_definition(self): @@ -106,6 +108,7 @@ def test_create_alert_definition(self): "service_type": service_type, "severity": 1, "status": "active", + "group_by": ["entity_id"], "entities": { "url": f"/monitor/services/dbaas/alert-definitions/67890/entities", "count": 1, @@ -125,6 +128,7 @@ def test_create_alert_definition(self): regions=[], entity_ids=["13217"], description="created via test", + group_by=["entity_id"], ) assert mock_post.call_url == url @@ -134,6 +138,7 @@ def test_create_alert_definition(self): assert "channel_ids" in mock_post.call_data assert mock_post.call_data["scope"] == "entity" assert mock_post.call_data["regions"] == [] + assert mock_post.call_data["group_by"] == ["entity_id"] assert isinstance(alert, AlertDefinition) assert alert.id == 67890 @@ -142,6 +147,7 @@ def test_create_alert_definition(self): ) assert alert.entities.count == 1 assert alert.entities.has_more_resources is False + assert alert.group_by == ["entity_id"] # fetch the same response from the client and assert resp = self.client.post(url, data={}) @@ -180,3 +186,73 @@ def test_alert_definition_entities(self): assert entities[2].label == "mydatabase-3" assert entities[2].url == "/v4/databases/mysql/instances/3" assert entities[2]._type == "dbaas" + + def test_clone_alert_definition(self): + service_type = "dbaas" + source_id = 12345 + url = ( + f"/monitor/services/{service_type}/alert-definitions/" + f"{source_id}/clone" + ) + + with self.mock_post(url) as mock_post: + alert = self.client.monitor.clone_alert_definition( + service_type=service_type, + id=source_id, + label="Cloned Alert", + ) + + assert mock_post.call_url == url + assert mock_post.call_data == {"label": "Cloned Alert"} + + assert isinstance(alert, AlertDefinition) + assert alert.id == 67891 + assert alert.label == "Cloned Alert" + assert alert.scope == "entity" + assert alert.group_by == ["entity_id"] + assert alert.rule_criteria is not None + assert alert.entities.url.endswith( + "/alert-definitions/67891/entities" + ) + + # fetch the same response from the client and assert + resp = self.client.post(url, data={}) + assert resp["label"] == "Cloned Alert" + + def test_clone_alert_definition_with_optional_fields(self): + service_type = "dbaas" + source_id = 12345 + url = ( + f"/monitor/services/{service_type}/alert-definitions/" + f"{source_id}/clone" + ) + + with self.mock_post(url) as mock_post: + self.client.monitor.clone_alert_definition( + service_type=service_type, + id=source_id, + label="Cloned Alert", + description="cloned via test", + scope="entity", # same as source alert definition + regions=[], + entity_ids=["13217"], + severity=1, + rule_criteria={"rules": []}, + trigger_conditions={"criteria_condition": "ALL"}, + channel_ids=[1, 2], + group_by=["entity_id"], + ) + + assert mock_post.call_url == url + assert mock_post.call_data["label"] == "Cloned Alert" + assert mock_post.call_data["description"] == "cloned via test" + assert mock_post.call_data["scope"] == "entity" + assert mock_post.call_data["regions"] == [] + assert mock_post.call_data["entity_ids"] == ["13217"] + assert mock_post.call_data["severity"] == 1 + assert mock_post.call_data["rule_criteria"] == {"rules": []} + assert mock_post.call_data["trigger_conditions"] == { + "criteria_condition": "ALL" + } + assert mock_post.call_data["channel_ids"] == [1, 2] + assert mock_post.call_data["group_by"] == ["entity_id"]