diff --git a/linode_api4/groups/monitor.py b/linode_api4/groups/monitor.py index 46f37561f..9aa321e94 100644 --- a/linode_api4/groups/monitor.py +++ b/linode_api4/groups/monitor.py @@ -18,10 +18,13 @@ MonitorService, MonitorServiceToken, ) +from linode_api4.objects.filtering import and_ from linode_api4.objects.monitor import ( AkamaiObjectStorageLogsDestinationDetails, + ChannelDetails, CustomHTTPSLogsDestinationDetails, LogsStreamDetails, + WebhookDetails, ) __all__ = [ @@ -423,6 +426,201 @@ 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. + The supported ``channel_type`` values are ``email`` and ``webhook``. + + 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 notification channel type (``"email"`` or ``"webhook"``). + :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. + """ + if channel_type == "webhook": + self._validate_webhook_details(details) + + 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 verify_webhook(self, webhook: WebhookDetails) -> bool: + """ + Verify that a webhook configuration can receive alert notifications. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-verify-webhook + + :param webhook: The webhook configuration to verify. + :type webhook: WebhookDetails + + :returns: Whether webhook verification succeeded. + :rtype: bool + + :raises ValueError: If the webhook configuration is invalid. + """ + self._validate_webhook_details(ChannelDetails(webhook=webhook)) + result = self.client.post( + "/monitor/alert-channels/verify", + data=webhook.dict, + ) + return result.get("success", False) + + @staticmethod + def _validate_webhook_details(details: ChannelDetails) -> None: + """Validate webhook channel details before sending an API request.""" + if not details or not details.webhook: + raise ValueError( + "Webhook details are required for webhook channel type" + ) + + webhook = details.webhook + if not webhook.endpoint_url: + raise ValueError( + "Webhook channel requires 'endpoint_url' to be specified" + ) + + if not webhook.authentication or not webhook.authentication.type: + raise ValueError( + "Webhook channel requires 'authentication.type' to be specified " + "('basic' or 'none')" + ) + + auth_type = webhook.authentication.type + if auth_type not in ("basic", "none"): + raise ValueError( + "Webhook authentication.type must be 'basic' or 'none'" + ) + + if auth_type == "basic": + auth_details = webhook.authentication.details + if not auth_details: + raise ValueError( + "Basic authentication requires 'authentication.details'" + ) + if not auth_details.basic_authentication_user: + raise ValueError( + "Basic authentication requires 'basic_authentication_user'" + ) + if not auth_details.basic_authentication_password: + raise ValueError( + "Basic authentication requires 'basic_authentication_password'" + ) + + if webhook.data_compression not in (None, "gzip", "none"): + raise ValueError( + "Webhook data_compression must be 'gzip' or 'none'" + ) + + if webhook.client_certificate_details: + certificate_details = webhook.client_certificate_details + if not all( + ( + certificate_details.client_ca_certificate, + certificate_details.client_certificate, + certificate_details.client_private_key, + ) + ): + raise ValueError( + "Client certificate configuration requires " + "'client_ca_certificate', 'client_certificate', and " + "'client_private_key'; 'tls_hostname' is optional" + ) + + if webhook.custom_headers: + for header in webhook.custom_headers: + if header.name and header.name.lower() == "content-type": + raise ValueError( + "Custom headers must not include 'Content-Type'; " + "it is managed by the API" + ) + + 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" + + 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, + ) + + result = [ + AlertDefinition.make_instance( + obj["id"], + self.client, + parent_id=obj.get("service_type"), + json=obj, + ) + for obj in response_json.get("data", []) + if "id" 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..eb444dc37 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -8,18 +8,22 @@ __all__ = [ "AggregateFunction", "AlertChannel", + "AlertChannelType", "AlertDefinition", "AlertDefinitionChannel", "AlertDefinitionEntity", "AlertEntities", "AlertScope", "AlertType", + "ChannelDetails", + "EmailDetails", "MonitorDashboard", "MonitorMetricsDefinition", "MonitorService", "MonitorServiceToken", "RuleCriteria", "TriggerConditions", + "WebhookDetails", "AkamaiObjectStorageLogsDestinationDetails", "AuthenticationType", "BasicAuthenticationDetails", @@ -276,8 +280,8 @@ class MonitorDashboard(Base): "id": Property(identifier=True), "created": Property(is_datetime=True), "label": Property(), - "service_type": Property(ServiceType), - "type": Property(DashboardType), + "service_type": Property(), + "type": Property(), "group_by": Property(), "widgets": Property(json_object=DashboardWidget), "updated": Property(is_datetime=True), @@ -294,7 +298,7 @@ class MonitorService(Base): api_endpoint = "/monitor/services/{service_type}" id_attribute = "service_type" properties = { - "service_type": Property(ServiceType), + "service_type": Property(), "label": Property(), "alert": Property(json_object=ServiceAlert), } @@ -437,6 +441,13 @@ class AlertScope(StrEnum): account = "account" +class AlertChannelType(StrEnum): + """Type values for alert channels.""" + + system = "system" + user = "user" + + @dataclass class AlertEntities(JSONObject): """ @@ -495,7 +506,7 @@ class AlertDefinition(DerivedBase): "entity_ids": Property(mutable=True), "description": Property(mutable=True), "service_class": Property(alias_of="class"), - "scope": Property(AlertScope), + "scope": Property(), "regions": Property(mutable=True), "entities": Property(json_object=AlertEntities), "channel_ids": Property(mutable=True), @@ -503,6 +514,48 @@ class AlertDefinition(DerivedBase): } +@dataclass +class BasicAuthenticationDetails(JSONObject): + """ + Includes additional parameters necessary to define basic authentication. + """ + + basic_authentication_user: Optional[str] = None + basic_authentication_password: Optional[str] = None + + +@dataclass +class DestinationAuthentication(JSONObject): + """ + Authentication details required to access the endpoint_url. + """ + + type: Optional[AuthenticationType] = None + details: Optional[BasicAuthenticationDetails] = None + + +@dataclass +class CustomHeader(JSONObject): + """ + Pairs of parameters used to optionally include custom headers in the request. + """ + + name: str = "" + value: str = "" + + +@dataclass +class ClientCertificateDetails(JSONObject): + """ + Contains TLS client certificate information to additionally secure the connection. + """ + + client_ca_certificate: Optional[str] = None + client_certificate: Optional[str] = None + client_private_key: Optional[str] = None + tls_hostname: Optional[str] = None + + @dataclass class EmailDetails(JSONObject): """ @@ -513,13 +566,35 @@ class EmailDetails(JSONObject): recipient_type: Optional[str] = None +@dataclass +class WebhookDetails(JSONObject): + """ + Represents webhook-specific details for an alert channel. + + Fields: + - endpoint_url: The URL where webhook events are sent. + - authentication: Authentication configuration for the webhook endpoint. + - data_compression: Compression method for webhook payloads ("gzip" or "none"). + - client_certificate_details: TLS client certificate configuration. + - custom_headers: List of custom HTTP headers to include in webhook requests. + """ + + endpoint_url: Optional[str] = None + authentication: Optional[DestinationAuthentication] = None + data_compression: Optional[str] = None + client_certificate_details: Optional[ClientCertificateDetails] = None + custom_headers: Optional[List[CustomHeader]] = None + + @dataclass class ChannelDetails(JSONObject): """ Represents the details block for an AlertChannel, which varies by channel type. + Supports email and webhook channel details. """ email: Optional[EmailDetails] = None + webhook: Optional[WebhookDetails] = None @dataclass @@ -541,25 +616,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.). + notifications (for example: email lists, webhooks, Slack, etc.). - API Documentation: https://techdocs.akamai.com/linode-api/reference/get-notification-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 - 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. + 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(), + "label": Property(mutable=True), "type": Property(), "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), @@ -568,48 +642,6 @@ class AlertChannel(Base): } -@dataclass -class BasicAuthenticationDetails(JSONObject): - """ - Includes additional parameters necessary to define basic authentication. - """ - - basic_authentication_user: Optional[str] = None - basic_authentication_password: Optional[str] = None - - -@dataclass -class DestinationAuthentication(JSONObject): - """ - Authentication details required to access the endpoint_url. - """ - - type: Optional[AuthenticationType] = None - details: Optional[BasicAuthenticationDetails] = None - - -@dataclass -class CustomHeader(JSONObject): - """ - Pairs of parameters used to optionally include custom headers in the request. - """ - - name: str = "" - value: str = "" - - -@dataclass -class ClientCertificateDetails(JSONObject): - """ - Contains TLS client certificate information to additionally secure the connection. - """ - - client_ca_certificate: Optional[str] = None - client_certificate: Optional[str] = None - client_private_key: Optional[str] = None - tls_hostname: Optional[str] = None - - @dataclass class LogsDestinationDetailsBase(JSONObject): """ 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..e9e805da1 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,14 @@ MonitorService, MonitorServiceToken, ) -from linode_api4.objects.monitor import AlertStatus +from linode_api4.objects.monitor import ( + AlertStatus, + ChannelDetails, + CustomHeader, + DestinationAuthentication, + EmailDetails, + WebhookDetails, +) def wait_for_alert_ready( @@ -240,25 +248,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 +307,71 @@ 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_webhook_channel_crud(test_linode_client): + """E2E: create, fetch, update, verify, and delete a webhook channel.""" + client = test_linode_client + label = f"python-sdk-webhook-change-{get_test_label()}" + created_webhook = None + + try: + created_webhook = client.monitor.channel_create( + label=label, + channel_type="webhook", + details=ChannelDetails( + webhook=WebhookDetails( + endpoint_url="https://httpbin.org/post", + authentication=DestinationAuthentication(type="none"), + data_compression="none", + custom_headers=[ + CustomHeader(name="x-trace-id", value="1234"), + ], + ) + ), + ) + + assert isinstance(created_webhook, AlertChannel) + assert created_webhook.id is not None + assert created_webhook.label == label + assert created_webhook.channel_type == "webhook" + assert created_webhook.details.webhook.authentication.type == "none" + + channels = list(client.monitor.alert_channels()) + found_channel = next( + ( + channel + for channel in channels + if channel.id == created_webhook.id + ), + None, + ) + assert found_channel is not None + assert found_channel.channel_type == "webhook" + + updated_label = f"{label}-updated" + created_webhook.label = updated_label + assert created_webhook.save() is True + + reloaded_webhook = client.load(AlertChannel, created_webhook.id) + assert reloaded_webhook.label == updated_label + + assert ( + client.monitor.verify_webhook(created_webhook.details.webhook) + is True + ) + finally: + if created_webhook: + created_webhook.delete() def test_alert_definition_entities(test_linode_client): @@ -324,6 +408,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 @@ -358,18 +554,32 @@ def test_integration_clone_alert_definition(test_linode_client): "trigger_occurrences": 1, } - 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/cloning alert definitions" - ) + # Get valid users to create an 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 for the alert channel + usernames = [users[0].username] created = None cloned_alert = 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, + ) + ), + ) + channels = [created_channel] + # Create the source alert definition created = client.monitor.create_alert_definition( service_type=service_type, @@ -429,3 +639,13 @@ def test_integration_clone_alert_definition(test_linode_client): AlertDefinition, created.id, service_type ) delete_source_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}" + ) diff --git a/test/unit/groups/monitor_api_test.py b/test/unit/groups/monitor_api_test.py index 8b2af9fe5..a40bdcc77 100644 --- a/test/unit/groups/monitor_api_test.py +++ b/test/unit/groups/monitor_api_test.py @@ -3,11 +3,20 @@ from linode_api4 import PaginatedList from linode_api4.objects import ( AggregateFunction, + AlertChannel, AlertDefinition, AlertDefinitionChannel, AlertDefinitionEntity, EntityMetricOptions, ) +from linode_api4.objects.monitor import ( + BasicAuthenticationDetails, + ChannelDetails, + CustomHeader, + DestinationAuthentication, + EmailDetails, + WebhookDetails, +) class MonitorAPITest(MonitorClientBaseCase): @@ -187,6 +196,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 @@ -256,3 +386,102 @@ def test_clone_alert_definition_with_optional_fields(self): } assert mock_post.call_data["channel_ids"] == [1, 2] assert mock_post.call_data["group_by"] == ["entity_id"] + + def test_create_webhook_channel(self): + create_url = "/monitor/alert-channels" + channel_id = 888 + response = { + "id": channel_id, + "label": "python-sdk-webhook-change", + "type": "user", + "channel_type": "webhook", + "details": { + "webhook": { + "endpoint_url": "https://example.com/webhook", + "authentication": {"type": "none"}, + "data_compression": "none", + "custom_headers": [{"name": "X-Trace-ID", "value": "1234"}], + } + }, + } + + with self.mock_post(response) as mock_post: + channel = self.client.monitor.channel_create( + label="python-sdk-webhook-change", + channel_type="webhook", + details=ChannelDetails( + webhook=WebhookDetails( + endpoint_url="https://example.com/webhook", + authentication=DestinationAuthentication(type="none"), + data_compression="none", + custom_headers=[ + CustomHeader(name="X-Trace-ID", value="1234") + ], + ) + ), + ) + + assert mock_post.call_url == create_url + assert mock_post.call_data == { + "label": "python-sdk-webhook-change", + "channel_type": "webhook", + "details": response["details"], + } + assert isinstance(channel, AlertChannel) + assert channel.channel_type == "webhook" + + def test_verify_webhook_channel(self): + webhook = WebhookDetails( + endpoint_url="https://example.com/webhook", + authentication=DestinationAuthentication( + type="basic", + details=BasicAuthenticationDetails( + basic_authentication_user="testuser", + basic_authentication_password="testpass", + ), + ), + data_compression="gzip", + ) + + with self.mock_post({"success": True}) as mock_post: + assert self.client.monitor.verify_webhook(webhook) is True + + assert mock_post.call_url == "/monitor/alert-channels/verify" + assert mock_post.call_data == webhook.dict + + def test_update_webhook_channel(self): + channel_id = 123 + channel_url = f"/monitor/alert-channels/{channel_id}" + response = { + "id": channel_id, + "label": "python-sdk-webhook-change", + "type": "user", + "channel_type": "webhook", + "details": { + "webhook": { + "endpoint_url": "https://example.com/webhook", + "authentication": {"type": "none"}, + "data_compression": "none", + } + }, + } + channel = AlertChannel(self.client, channel_id, response) + updated_response = {**response, "label": "webhook-updated"} + + with self.mock_put(updated_response) as mock_put: + channel.label = "webhook-updated" + assert channel.save() is True + + assert mock_put.call_url == channel_url + assert mock_put.call_data["label"] == "webhook-updated" + assert channel.channel_type == "webhook" + + def test_delete_webhook_channel(self): + channel_id = 123 + channel = AlertChannel(self.client, channel_id, {"id": channel_id}) + + with self.mock_delete() as mock_delete: + assert channel.delete() is True + assert ( + mock_delete.call_url == f"/monitor/alert-channels/{channel_id}" + ) diff --git a/test/unit/objects/monitor_test.py b/test/unit/objects/monitor_test.py index c0999e485..285e04a63 100644 --- a/test/unit/objects/monitor_test.py +++ b/test/unit/objects/monitor_test.py @@ -12,11 +12,17 @@ ) from linode_api4.objects.monitor import ( AkamaiObjectStorageLogsDestinationDetails, + BasicAuthenticationDetails, + ChannelDetails, + ClientCertificateDetails, + CustomHeader, CustomHTTPSLogsDestinationDetails, DestinationAuthentication, + EmailDetails, LogsDestinationDetailsBase, LogsStreamDetails, LogsStreamType, + WebhookDetails, ) @@ -146,7 +152,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 +196,200 @@ 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) + + def test_get_webhook_channel(self): + channel_id = 888 + channel_url = f"/monitor/alert-channels/{channel_id}" + response = { + "id": channel_id, + "label": "python-sdk-webhook-change", + "type": "user", + "channel_type": "webhook", + "details": { + "webhook": { + "endpoint_url": "https://example.com/webhook", + "authentication": {"type": "none"}, + "data_compression": "none", + "custom_headers": [{"name": "X-Trace-ID", "value": "1234"}], + } + }, + } + + with self.mock_get(response) as mock_get: + channel = self.client.load(AlertChannel, channel_id) + + self.assertEqual(mock_get.call_url, channel_url) + self.assertEqual(channel.channel_type, "webhook") + self.assertIsInstance(channel.details.webhook, WebhookDetails) + self.assertEqual( + channel.details.webhook.endpoint_url, + "https://example.com/webhook", + ) + self.assertEqual( + channel.details.webhook.authentication.type, + "none", + ) + self.assertEqual( + channel.details.webhook.custom_headers[0].name, + "X-Trace-ID", + ) + + def test_webhook_channel_validation(self): + valid_authentication = DestinationAuthentication(type="none") + + invalid_details = [ + ( + WebhookDetails( + endpoint_url=None, + authentication=valid_authentication, + ), + "endpoint_url", + ), + ( + WebhookDetails( + endpoint_url="https://example.com/webhook", + authentication=DestinationAuthentication(type=None), + ), + "authentication.type", + ), + ( + WebhookDetails( + endpoint_url="https://example.com/webhook", + authentication=DestinationAuthentication(type="oauth2"), + ), + "'basic' or 'none'", + ), + ( + WebhookDetails( + endpoint_url="https://example.com/webhook", + authentication=DestinationAuthentication( + type="basic", + details=BasicAuthenticationDetails( + basic_authentication_user=None, + basic_authentication_password="password", + ), + ), + ), + "basic_authentication_user", + ), + ( + WebhookDetails( + endpoint_url="https://example.com/webhook", + authentication=DestinationAuthentication( + type="basic", + details=BasicAuthenticationDetails( + basic_authentication_user="user", + basic_authentication_password=None, + ), + ), + ), + "basic_authentication_password", + ), + ( + WebhookDetails( + endpoint_url="https://example.com/webhook", + authentication=valid_authentication, + data_compression="brotli", + ), + "data_compression", + ), + ( + WebhookDetails( + endpoint_url="https://example.com/webhook", + authentication=valid_authentication, + client_certificate_details=ClientCertificateDetails(), + ), + "client_ca_certificate", + ), + ( + WebhookDetails( + endpoint_url="https://example.com/webhook", + authentication=valid_authentication, + custom_headers=[ + CustomHeader( + name="Content-Type", + value="application/json", + ) + ], + ), + "Content-Type", + ), + ] + + for webhook, message in invalid_details: + with self.subTest(message=message): + with self.assertRaisesRegex(ValueError, message): + self.client.monitor._validate_webhook_details( + ChannelDetails(webhook=webhook) + ) + class LogsDestinationTest(ClientBaseCase): """