diff --git a/linode_api4/groups/monitor.py b/linode_api4/groups/monitor.py index 46f37561f..a74f30983 100644 --- a/linode_api4/groups/monitor.py +++ b/linode_api4/groups/monitor.py @@ -18,8 +18,10 @@ MonitorService, MonitorServiceToken, ) +from linode_api4.objects.filtering import and_ from linode_api4.objects.monitor import ( AkamaiObjectStorageLogsDestinationDetails, + ChannelDetails, CustomHTTPSLogsDestinationDetails, LogsStreamDetails, ) @@ -423,6 +425,110 @@ def alert_definition_entities( endpoint=endpoint, ) + def channel_create( + self, + label: str, + channel_type: str, + details: ChannelDetails, + ) -> AlertChannel: + """ + Creates a new alert channel for the authenticated account. + + An alert channel defines a notification destination (for example: an + email list) that can be associated with one or more alert definitions. + Currently only ``email`` is supported as a ``channel_type``. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-notification-channel + + :param label: Human-readable name for the new alert channel. + :type label: str + :param channel_type: The type of notification channel (e.g. ``"email"``). + :type channel_type: str + :param details: Notification-type-specific configuration. + :type details: ChannelDetails + + :returns: The newly created :class:`AlertChannel`. + :rtype: AlertChannel + + .. note:: + If you need to obtain a single :class:`AlertChannel`, use :meth:`LinodeClient.load`. + Example: ``client.load(AlertChannel, channel_id)``. + For updating an alert channel, use the ``save()`` method on the :class:`AlertChannel` object. + For deleting an alert channel, use the ``delete()`` method directly on the :class:`AlertChannel` object. + """ + params = { + "label": label, + "channel_type": channel_type, + "details": details.dict, + } + + result = self.client.post("/monitor/alert-channels", data=params) + + if "id" not in result: + raise UnexpectedResponseError( + "Unexpected response when creating alert channel!", + json=result, + ) + + return AlertChannel(self.client, result["id"], result) + + def alert_channel_alerts(self, channel_id: int, *filters) -> PaginatedList: + """ + Retrieve all alerts associated with a specific alert channel. + + Returns a paginated collection of alert definitions associated with the + specified alert channel. This allows you to see which alert definitions + are configured to notify this specific channel. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-notification-channel-alerts + + :param channel_id: The ID of the alert channel to retrieve alerts for. + :type channel_id: int + :param filters: Optional filter expressions to apply to the collection. + See :doc:`Filtering Collections` for details. + + :returns: A paginated list of alert definitions associated with this channel. + :rtype: PaginatedList[AlertDefinition] + """ + endpoint = f"/monitor/alert-channels/{channel_id}/alerts" + + # Build filter dict if filters provided + parsed_filters = None + if filters: + parsed_filters = ( + and_(*filters).dct if len(filters) > 1 else filters[0].dct + ) + + response_json = self.client.get(endpoint, filters=parsed_filters) + + if "data" not in response_json: + raise UnexpectedResponseError( + "Unexpected response when retrieving alert channel alerts!", + json=response_json, + ) + + # Create AlertDefinition objects with proper parent_id (service_type) + result = [ + AlertDefinition.make_instance( + obj["id"], + self.client, + parent_id=obj["service_type"], + json=obj, + ) + for obj in response_json.get("data", []) + if "id" in obj and "service_type" in obj + ] + + return PaginatedList( + self.client, + endpoint[1:], + page=result, + max_pages=response_json.get("pages", 1), + total_items=response_json.get("results", len(result)), + parent_id=None, + filters=parsed_filters, + ) + def destinations(self, *filters) -> PaginatedList: """ List available logs destinations. diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index c23e4cead..38cadc538 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -8,6 +8,7 @@ __all__ = [ "AggregateFunction", "AlertChannel", + "AlertChannelType", "AlertDefinition", "AlertDefinitionChannel", "AlertDefinitionEntity", @@ -437,6 +438,15 @@ class AlertScope(StrEnum): account = "account" +class AlertChannelType(StrEnum): + """ + Type values for alert channels. + """ + + system = "system" + user = "user" + + @dataclass class AlertEntities(JSONObject): """ @@ -541,25 +551,24 @@ 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-notification-channels + notifications (for example: email lists, webhooks, Slack, etc.). - This class maps to the Monitor API's `/monitor/alert-channels` resource - and is used by the SDK to list, load, and inspect channels. + API Documentation: + List/Get: https://techdocs.akamai.com/linode-api/reference/get-notification-channel + Create: https://techdocs.akamai.com/linode-api/reference/post-notification-channel - NOTE: Only read operations are supported for AlertChannel at this time. - Create, update, and delete (CRUD) operations are not allowed. + This class maps to the Monitor API's ``/monitor/alert-channels`` resource + and is used by the SDK to list, load, create, and inspect channels. """ api_endpoint = "/monitor/alert-channels/{id}" properties = { "id": Property(identifier=True), - "label": Property(), - "type": Property(), + "label": Property(mutable=True), + "type": Property(AlertChannelType), "channel_type": Property(), - "details": Property(mutable=False, json_object=ChannelDetails), + "details": Property(mutable=True, json_object=ChannelDetails), "alerts": Property(mutable=False, json_object=AlertInfo), "created": Property(is_datetime=True), "updated": Property(is_datetime=True), diff --git a/test/fixtures/monitor_alert-channels_123.json b/test/fixtures/monitor_alert-channels_123.json new file mode 100644 index 000000000..3a677da7a --- /dev/null +++ b/test/fixtures/monitor_alert-channels_123.json @@ -0,0 +1,24 @@ +{ + "id": 123, + "label": "alert notification channel", + "type": "user", + "channel_type": "email", + "details": { + "email": { + "usernames": [ + "admin-user1", + "admin-user2" + ], + "recipient_type": "user" + } + }, + "alerts": { + "url": "/monitor/alert-channels/123/alerts", + "type": "alerts-definitions", + "alert_count": 2 + }, + "created": "2024-01-01T00:00:00", + "updated": "2024-01-01T00:00:00", + "created_by": "tester", + "updated_by": "tester" +} diff --git a/test/fixtures/monitor_alert-channels_123_alerts.json b/test/fixtures/monitor_alert-channels_123_alerts.json new file mode 100644 index 000000000..d6cc9f89f --- /dev/null +++ b/test/fixtures/monitor_alert-channels_123_alerts.json @@ -0,0 +1,21 @@ +{ + "data": [ + { + "id": 12345, + "label": "DBAAS Alert 1", + "service_type": "dbaas", + "type": "alerts-definitions", + "url": "/monitor/services/dbaas/alerts-definitions/12345" + }, + { + "id": 12346, + "label": "DBAAS Alert 2", + "service_type": "dbaas", + "type": "alerts-definitions", + "url": "/monitor/services/dbaas/alerts-definitions/12346" + } + ], + "page": 1, + "pages": 1, + "results": 2 +} diff --git a/test/fixtures/monitor_services_dbaas_alert-definitions.json b/test/fixtures/monitor_services_dbaas_alert-definitions.json index c7b725524..704b3ec56 100644 --- a/test/fixtures/monitor_services_dbaas_alert-definitions.json +++ b/test/fixtures/monitor_services_dbaas_alert-definitions.json @@ -41,7 +41,7 @@ "metric": "cpu_usage", "operator": "gt", "threshold": 90, - "unit": "percent" + "unit": "%" } ] }, diff --git a/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json b/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json index f88dd7503..36bd66ddd 100644 --- a/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json +++ b/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json @@ -39,7 +39,7 @@ "metric": "cpu_usage", "operator": "gt", "threshold": 90, - "unit": "percent" + "unit": "%" } ] }, diff --git a/test/fixtures/monitor_services_dbaas_metric-definitions.json b/test/fixtures/monitor_services_dbaas_metric-definitions.json index c493b23a3..545013562 100644 --- a/test/fixtures/monitor_services_dbaas_metric-definitions.json +++ b/test/fixtures/monitor_services_dbaas_metric-definitions.json @@ -22,7 +22,7 @@ "metric": "cpu_usage", "metric_type": "gauge", "scrape_interval": "60s", - "unit": "percent" + "unit": "%" }, { "available_aggregate_functions": [ diff --git a/test/integration/models/monitor/test_monitor.py b/test/integration/models/monitor/test_monitor.py index 996f5f728..f3d7000c4 100644 --- a/test/integration/models/monitor/test_monitor.py +++ b/test/integration/models/monitor/test_monitor.py @@ -9,6 +9,7 @@ from linode_api4 import LinodeClient, PaginatedList from linode_api4.objects import ( + AlertChannel, AlertDefinition, AlertDefinitionEntity, ApiError, @@ -17,7 +18,11 @@ MonitorService, MonitorServiceToken, ) -from linode_api4.objects.monitor import AlertStatus +from linode_api4.objects.monitor import ( + AlertStatus, + ChannelDetails, + EmailDetails, +) def wait_for_alert_ready( @@ -240,25 +245,36 @@ def test_integration_create_get_update_delete_alert_definition( 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() - ) # TODO: create channel instead of relying on pre-existing one - if not channels: - pytest.skip( - "No alert channels available on account for creating alert definitions" - ) + # Get valid users to create an alert channel for the alert definition + users = list(client.account.users()) + if len(users) == 0: + pytest.skip("No account users available for creating alert channels") + + # Use the first user for the alert channel + usernames = [users[0].username] created = None + created_channel = None try: + # Create a new alert channel for this test + created_channel = client.monitor.channel_create( + label=f"{get_test_label()}-channel-{int(time.time())}", + channel_type="email", + details=ChannelDetails( + email=EmailDetails( + recipient_type="user", + usernames=usernames, + ) + ), + ) # 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], + channel_ids=[created_channel.id], rule_criteria=rule_criteria, trigger_conditions=trigger_conditions, ) @@ -288,6 +304,15 @@ def test_integration_create_get_update_delete_alert_definition( AlertDefinition, created.id, service_type ) delete_alert.delete() + if created_channel: + # Clean up the created channel + try: + created_channel.delete() + except Exception as e: + # Log but don't fail if cleanup fails + print( + f"Warning: Failed to delete channel {created_channel.id}: {e}" + ) def test_alert_definition_entities(test_linode_client): @@ -324,6 +349,118 @@ def test_alert_definition_entities(test_linode_client): assert entity._type == service_type +def test_integration_create_get_update_delete_alert_channel(test_linode_client): + """E2E: create an alert channel, fetch it, update it, then delete it. + + This test creates an alert channel with email details, retrieves it, + updates it, and then deletes it. It ensures the full CRUD feature is + working end-to-end against the actual API. + """ + client = test_linode_client + label = "pythonsdk-alert-channel-test" + + created_channel = None + + try: + # Get valid users to use for the email alert channel + users = list(client.account.users()) + if len(users) == 0: + pytest.skip( + "No account users available for creating alert channels" + ) + + # Use the first user, or first two if available + usernames = [users[0].username] + if len(users) > 1: + usernames.append(users[1].username) + + # Create an alert channel with email details + created_channel = client.monitor.channel_create( + label=label, + channel_type="email", + details=ChannelDetails( + email=EmailDetails( + recipient_type="user", + usernames=usernames, + ) + ), + ) + + # Assert the created channel has expected properties + assert isinstance(created_channel, AlertChannel) + assert created_channel.id is not None + assert created_channel.label == label + assert created_channel.channel_type == "email" + assert created_channel.details is not None + + # Fetch the channel to verify it exists + channels = list(client.monitor.alert_channels()) + assert len(channels) > 0, "No channels found after creation" + + # Find the created channel in the list + found_channel = None + for ch in channels: + if ch.id == created_channel.id: + found_channel = ch + break + + assert found_channel is not None, "Created channel not found in list" + assert found_channel.label == label + assert found_channel.channel_type == "email" + + # Update the channel label + updated_label = f"{label}-updated" + created_channel.label = updated_label + result = created_channel.save() + assert result is True, "Failed to update channel" + + # Fetch the updated channel to verify the change + reloaded_channel = client.load(AlertChannel, created_channel.id) + assert ( + reloaded_channel.label == updated_label + ), "Channel label was not updated" + + finally: + if created_channel: + # Clean up: delete the created channel + try: + created_channel.delete() + except Exception as e: + # Log but don't fail if cleanup fails + print( + f"Warning: Failed to delete channel {created_channel.id}: {e}" + ) + + +def test_integration_alert_channel_alerts(test_linode_client): + """Test retrieving alerts associated with a specific alert channel. + + This test fetches alerts for an existing alert channel and verifies + the paginated list of alert definitions is returned correctly. + """ + client = test_linode_client + + # Get an existing alert channel to test with + channels = list(client.monitor.alert_channels()) + if len(channels) == 0: + pytest.skip("No alert channels available on account for testing") + + channel_id = channels[0].id + + # Test the alert_channel_alerts() method + alerts = client.monitor.alert_channel_alerts(channel_id) + + assert isinstance(alerts, PaginatedList) + + # If there are alerts, verify their structure + if len(alerts) > 0: + alert = alerts[0] + assert isinstance(alert, AlertDefinition) + assert alert.id is not None + assert alert.label is not None + assert alert.service_type is not None + + def test_integration_clone_alert_definition(test_linode_client): """E2E: create a source alert definition, clone it, then delete both.""" client = test_linode_client diff --git a/test/unit/groups/monitor_api_test.py b/test/unit/groups/monitor_api_test.py index 8b2af9fe5..1777013d8 100644 --- a/test/unit/groups/monitor_api_test.py +++ b/test/unit/groups/monitor_api_test.py @@ -3,11 +3,13 @@ from linode_api4 import PaginatedList from linode_api4.objects import ( AggregateFunction, + AlertChannel, AlertDefinition, AlertDefinitionChannel, AlertDefinitionEntity, EntityMetricOptions, ) +from linode_api4.objects.monitor import ChannelDetails, EmailDetails class MonitorAPITest(MonitorClientBaseCase): @@ -187,6 +189,127 @@ def test_alert_definition_entities(self): assert entities[2].url == "/v4/databases/mysql/instances/3" assert entities[2]._type == "dbaas" + def test_create_update_delete_alert_channel(self): + """ + E2E test for alert channel CRUD: create, update, and delete. + Verifies the full lifecycle of an alert channel. + """ + create_url = "/monitor/alert-channels" + channel_id = 789 + channel_url = f"{create_url}/{channel_id}" + + # Create channel + create_response = { + "id": channel_id, + "label": "Test Channel", + "type": "user", + "channel_type": "email", + "details": { + "email": { + "usernames": ["test_user1", "test_user2"], + "recipient_type": "user", + } + }, + "alerts": { + "url": f"{channel_url}/alerts", + "type": "alerts-definitions", + "alert_count": 0, + }, + "created": "2024-01-01T00:00:00", + "updated": "2024-01-01T00:00:00", + "created_by": "test_user1", + "updated_by": "test_user1", + } + + with self.mock_post(create_response) as mock_post: + channel = self.client.monitor.channel_create( + label="Test Channel", + channel_type="email", + details=ChannelDetails( + email=EmailDetails( + recipient_type="user", + usernames=["test_user1", "test_user2"], + ) + ), + ) + + assert mock_post.call_url == create_url + assert isinstance(channel, AlertChannel) + assert channel.id == channel_id + assert channel.label == "Test Channel" + + # Update channel + updated_response = create_response.copy() + updated_response["label"] = "Test Channel Updated" + updated_response["updated"] = "2024-01-02T00:00:00" + + with self.mock_put(updated_response) as mock_put: + channel.label = "Test Channel Updated" + result = channel.save() + + assert mock_put.call_url == channel_url + assert result is True + assert channel.label == "Test Channel Updated" + + # Delete channel + with self.mock_delete() as mock_delete: + result = channel.delete() + + assert mock_delete.call_url == channel_url + assert result is True + + def test_alert_channel_alerts(self): + """ + Test retrieval of alerts associated with a specific alert channel. + Verifies the alert_channel_alerts method returns a paginated list + of AlertDefinition objects associated with the channel. + """ + channel_id = 123 + alerts_url = f"/monitor/alert-channels/{channel_id}/alerts" + + alerts_response = { + "data": [ + { + "id": 12345, + "label": "DBAAS Alert 1", + "service_type": "dbaas", + "type": "alerts-definitions", + "url": "/monitor/services/dbaas/alerts-definitions/12345", + }, + { + "id": 12346, + "label": "DBAAS Alert 2", + "service_type": "dbaas", + "type": "alerts-definitions", + "url": "/monitor/services/dbaas/alerts-definitions/12346", + }, + ], + "page": 1, + "pages": 1, + "results": 2, + } + + with self.mock_get(alerts_response) as mock_get: + alerts = self.client.monitor.alert_channel_alerts( + channel_id=channel_id + ) + + assert mock_get.call_url == alerts_url + assert isinstance(alerts, PaginatedList) + assert len(alerts) == 2 + + # Verify first alert + assert isinstance(alerts[0], AlertDefinition) + assert alerts[0].id == 12345 + assert alerts[0].label == "DBAAS Alert 1" + assert alerts[0].service_type == "dbaas" + + # Verify second alert + assert isinstance(alerts[1], AlertDefinition) + assert alerts[1].id == 12346 + assert alerts[1].label == "DBAAS Alert 2" + assert alerts[1].service_type == "dbaas" + def test_clone_alert_definition(self): service_type = "dbaas" source_id = 12345 diff --git a/test/unit/objects/monitor_test.py b/test/unit/objects/monitor_test.py index c0999e485..b6534fe72 100644 --- a/test/unit/objects/monitor_test.py +++ b/test/unit/objects/monitor_test.py @@ -12,8 +12,10 @@ ) from linode_api4.objects.monitor import ( AkamaiObjectStorageLogsDestinationDetails, + ChannelDetails, CustomHTTPSLogsDestinationDetails, DestinationAuthentication, + EmailDetails, LogsDestinationDetailsBase, LogsStreamDetails, LogsStreamType, @@ -146,7 +148,7 @@ def test_metric_definitions(self): self.assertEqual(metrics[0].metric, "cpu_usage") self.assertEqual(metrics[0].metric_type, "gauge") self.assertEqual(metrics[0].scrape_interval, "60s") - self.assertEqual(metrics[0].unit, "percent") + self.assertEqual(metrics[0].unit, "%") self.assertEqual(metrics[0].dimensions[0].dimension_label, "node_type") self.assertEqual(metrics[0].dimensions[0].label, "Node Type") self.assertEqual( @@ -190,6 +192,74 @@ def test_alert_channels(self): ) self.assertEqual(channels[0].alerts.alert_count, 0) + def test_create_update_delete_channel(self): + """ + Test CRUD operations for AlertChannel: create, update, and delete. + Verifies the full lifecycle of an alert channel object. + """ + create_url = "/monitor/alert-channels" + channel_id = 999 + channel_url = f"{create_url}/{channel_id}" + + # CREATE: Create the channel via channel_create() + create_response = { + "id": channel_id, + "label": "CRUD Test Channel", + "type": "user", + "channel_type": "email", + "details": { + "email": { + "usernames": ["crud_user1", "crud_user2"], + "recipient_type": "user", + } + }, + "alerts": { + "url": f"{channel_url}/alerts", + "type": "alerts-definitions", + "alert_count": 0, + }, + "created": "2024-01-01T00:00:00", + "updated": "2024-01-01T00:00:00", + "created_by": "crud_user1", + "updated_by": "crud_user1", + } + + with self.mock_post(create_response) as m_post: + channel = self.client.monitor.channel_create( + label="CRUD Test Channel", + channel_type="email", + details=ChannelDetails( + email=EmailDetails( + recipient_type="user", + usernames=["crud_user1", "crud_user2"], + ) + ), + ) + self.assertEqual(m_post.call_url, create_url) + self.assertIsInstance(channel, AlertChannel) + self.assertEqual(channel.id, channel_id) + self.assertEqual(channel.label, "CRUD Test Channel") + + # UPDATE: Update the channel label + updated_response = create_response.copy() + updated_response["label"] = "CRUD Test Channel Updated" + updated_response["updated"] = "2024-01-02T00:00:00" + + with self.mock_put(updated_response) as m_put: + channel.label = "CRUD Test Channel Updated" + result = channel.save() + + self.assertEqual(m_put.call_url, channel_url) + self.assertTrue(result) + self.assertEqual(channel.label, "CRUD Test Channel Updated") + + # DELETE: Delete the channel + with self.mock_delete() as m_delete: + result = channel.delete() + + self.assertEqual(m_delete.call_url, channel_url) + self.assertTrue(result) + class LogsDestinationTest(ClientBaseCase): """