Skip to content

Commit 5eea1dc

Browse files
committed
feat: add webhook channel support for alerts
1 parent 006bf41 commit 5eea1dc

5 files changed

Lines changed: 711 additions & 496 deletions

File tree

linode_api4/groups/monitor.py

Lines changed: 192 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,16 @@
1818
MonitorService,
1919
MonitorServiceToken,
2020
)
21-
from linode_api4.objects.filtering import and_
2221
from linode_api4.objects.monitor import (
2322
AkamaiObjectStorageLogsDestinationDetails,
23+
BasicAuthenticationDetails,
2424
ChannelDetails,
25+
CustomHeader,
2526
CustomHTTPSLogsDestinationDetails,
27+
DestinationAuthentication,
28+
EmailDetails,
2629
LogsStreamDetails,
30+
WebhookDetails,
2731
)
2832

2933
__all__ = [
@@ -215,6 +219,193 @@ def alert_channels(self, *filters) -> PaginatedList:
215219
"""
216220
return self.client._get_and_filter(AlertChannel, *filters)
217221

222+
def channel_create(
223+
self,
224+
label: str,
225+
channel_type: str,
226+
details: "ChannelDetails",
227+
) -> AlertChannel:
228+
"""
229+
Create a new alert channel.
230+
231+
Alert channels define destinations for alert notifications. Supported
232+
channel types include email, webhook, PagerDuty, and Slack.
233+
234+
**Webhook Channel Constraints:**
235+
- If ``channel_type`` is "webhook", the following are required:
236+
- ``details.webhook.endpoint_url`` must be provided
237+
- ``details.webhook.authentication.type`` must be specified ("basic" or "none")
238+
- If ``authentication.type`` is "basic", both ``basic_authentication_user`` and
239+
``basic_authentication_password`` must be provided in ``details.webhook.authentication.details``
240+
- Client Certificate Configuration (Optional but must be complete):
241+
- If ``details.webhook.client_certificate_details`` is provided, all three certificates
242+
must be included: ``client_ca_certificate``, ``client_certificate``, and ``client_private_key``
243+
- ``tls_hostname`` is optional
244+
- Custom Headers:
245+
- ``Content-Type`` header must NOT be set by the user; it will be managed by the API
246+
247+
API Documentation: https://techdocs.akamai.com/linode-api/reference/post-notification-channel
248+
249+
:param label: A human-readable name for the alert channel.
250+
:type label: str
251+
:param channel_type: The channel type (e.g., ``"email"``, ``"webhook"``).
252+
:type channel_type: str
253+
:param details: Configuration details specific to the channel type.
254+
:type details: ChannelDetails
255+
256+
:returns: The newly created alert channel.
257+
:rtype: AlertChannel
258+
259+
:raises ValueError: If webhook channel configuration is invalid or missing required fields.
260+
"""
261+
pass
262+
263+
# Validate webhook channel requirements
264+
if channel_type == "webhook":
265+
self._validate_webhook_details(details)
266+
267+
params = {
268+
"label": label,
269+
"channel_type": channel_type,
270+
"details": (
271+
details._serialize()
272+
if hasattr(details, "_serialize")
273+
else details
274+
),
275+
}
276+
277+
result = self.client.post("/monitor/alert-channels", data=params)
278+
279+
if "id" not in result:
280+
raise UnexpectedResponseError(
281+
"Unexpected response when creating alert channel!",
282+
json=result,
283+
)
284+
285+
return AlertChannel(self.client, result["id"], result)
286+
287+
def verify_webhook(
288+
self,
289+
webhook: "WebhookDetails",
290+
) -> bool:
291+
"""
292+
Verify a webhook configuration by testing connectivity to the endpoint.
293+
294+
This endpoint validates that the webhook endpoint is reachable and
295+
accepts the request format. It's recommended to verify webhook
296+
configurations before creating a webhook channel.
297+
298+
**Webhook Configuration Requirements:**
299+
- ``endpoint_url`` must be provided
300+
- ``authentication.type`` must be specified ("basic" or "none")
301+
- If ``authentication.type`` is "basic", both ``basic_authentication_user`` and
302+
``basic_authentication_password`` must be provided
303+
- Client certificates (if used) must include all three: ``client_ca_certificate``,
304+
``client_certificate``, and ``client_private_key``
305+
306+
API Documentation: https://techdocs.akamai.com/linode-api/reference/post-verify-webhook
307+
308+
:param webhook: The webhook configuration to verify.
309+
:type webhook: WebhookDetails
310+
311+
:returns: True if verification succeeds.
312+
:rtype: bool
313+
314+
:raises ValueError: If webhook configuration is invalid.
315+
:raises ApiError: If the webhook verification fails.
316+
"""
317+
from linode_api4.objects.monitor import ChannelDetails
318+
319+
# Validate webhook configuration
320+
self._validate_webhook_details(ChannelDetails(webhook=webhook))
321+
322+
data = {
323+
"webhook": (
324+
webhook._serialize()
325+
if hasattr(webhook, "_serialize")
326+
else webhook
327+
),
328+
}
329+
330+
result = self.client.post("/monitor/alert-channels/verify", data=data)
331+
332+
return result.get("success", True)
333+
334+
def _validate_webhook_details(self, details: "ChannelDetails") -> None:
335+
"""
336+
Validate webhook channel details against API requirements.
337+
338+
:param details: The channel details to validate.
339+
:type details: ChannelDetails
340+
341+
:raises ValueError: If validation fails.
342+
"""
343+
if not details or not details.webhook:
344+
raise ValueError(
345+
"Webhook details are required for webhook channel type"
346+
)
347+
348+
webhook = details.webhook
349+
350+
# Validate required endpoint_url
351+
if not webhook.endpoint_url:
352+
raise ValueError(
353+
"Webhook channel requires 'endpoint_url' to be specified"
354+
)
355+
356+
# Validate required authentication.type
357+
if not webhook.authentication or not webhook.authentication.type:
358+
raise ValueError(
359+
"Webhook channel requires 'authentication.type' to be specified "
360+
"(e.g., 'basic' or 'none')"
361+
)
362+
363+
auth_type = webhook.authentication.type
364+
if auth_type == "basic":
365+
# For basic auth, both username and password are required
366+
if not webhook.authentication.details:
367+
raise ValueError(
368+
"Basic authentication requires 'authentication.details' to be specified"
369+
)
370+
371+
auth_details = webhook.authentication.details
372+
if not auth_details.basic_authentication_user:
373+
raise ValueError(
374+
"Basic authentication requires 'basic_authentication_user' to be specified"
375+
)
376+
377+
if not auth_details.basic_authentication_password:
378+
raise ValueError(
379+
"Basic authentication requires 'basic_authentication_password' to be specified"
380+
)
381+
382+
# Validate client certificate configuration (all three must be present together)
383+
if webhook.client_certificate_details:
384+
cert_details = webhook.client_certificate_details
385+
386+
# Check if any certificate field is present
387+
has_ca_cert = bool(cert_details.client_ca_certificate)
388+
has_client_cert = bool(cert_details.client_certificate)
389+
has_private_key = bool(cert_details.client_private_key)
390+
391+
# If any certificate field is present, all must be present
392+
if has_ca_cert or has_client_cert or has_private_key:
393+
if not (has_ca_cert and has_client_cert and has_private_key):
394+
raise ValueError(
395+
"Client certificate configuration requires all three to be specified: "
396+
"'client_ca_certificate', 'client_certificate', and 'client_private_key'. "
397+
"'tls_hostname' is optional."
398+
)
399+
400+
# Validate custom headers don't include Content-Type
401+
if webhook.custom_headers:
402+
for header in webhook.custom_headers:
403+
if header.name and header.name.lower() == "content-type":
404+
raise ValueError(
405+
"Custom headers must NOT include 'Content-Type'; "
406+
"it will be managed by the API"
407+
)
408+
218409
def create_alert_definition(
219410
self,
220411
service_type: str,
@@ -425,110 +616,6 @@ def alert_definition_entities(
425616
endpoint=endpoint,
426617
)
427618

428-
def channel_create(
429-
self,
430-
label: str,
431-
channel_type: str,
432-
details: ChannelDetails,
433-
) -> AlertChannel:
434-
"""
435-
Creates a new alert channel for the authenticated account.
436-
437-
An alert channel defines a notification destination (for example: an
438-
email list) that can be associated with one or more alert definitions.
439-
Currently only ``email`` is supported as a ``channel_type``.
440-
441-
API Documentation: https://techdocs.akamai.com/linode-api/reference/post-notification-channel
442-
443-
:param label: Human-readable name for the new alert channel.
444-
:type label: str
445-
:param channel_type: The type of notification channel (e.g. ``"email"``).
446-
:type channel_type: str
447-
:param details: Notification-type-specific configuration.
448-
:type details: ChannelDetails
449-
450-
:returns: The newly created :class:`AlertChannel`.
451-
:rtype: AlertChannel
452-
453-
.. note::
454-
If you need to obtain a single :class:`AlertChannel`, use :meth:`LinodeClient.load`.
455-
Example: ``client.load(AlertChannel, channel_id)``.
456-
For updating an alert channel, use the ``save()`` method on the :class:`AlertChannel` object.
457-
For deleting an alert channel, use the ``delete()`` method directly on the :class:`AlertChannel` object.
458-
"""
459-
params = {
460-
"label": label,
461-
"channel_type": channel_type,
462-
"details": details.dict,
463-
}
464-
465-
result = self.client.post("/monitor/alert-channels", data=params)
466-
467-
if "id" not in result:
468-
raise UnexpectedResponseError(
469-
"Unexpected response when creating alert channel!",
470-
json=result,
471-
)
472-
473-
return AlertChannel(self.client, result["id"], result)
474-
475-
def alert_channel_alerts(self, channel_id: int, *filters) -> PaginatedList:
476-
"""
477-
Retrieve all alerts associated with a specific alert channel.
478-
479-
Returns a paginated collection of alert definitions associated with the
480-
specified alert channel. This allows you to see which alert definitions
481-
are configured to notify this specific channel.
482-
483-
API Documentation: https://techdocs.akamai.com/linode-api/reference/get-notification-channel-alerts
484-
485-
:param channel_id: The ID of the alert channel to retrieve alerts for.
486-
:type channel_id: int
487-
:param filters: Optional filter expressions to apply to the collection.
488-
See :doc:`Filtering Collections</linode_api4/objects/filtering>` for details.
489-
490-
:returns: A paginated list of alert definitions associated with this channel.
491-
:rtype: PaginatedList[AlertDefinition]
492-
"""
493-
endpoint = f"/monitor/alert-channels/{channel_id}/alerts"
494-
495-
# Build filter dict if filters provided
496-
parsed_filters = None
497-
if filters:
498-
parsed_filters = (
499-
and_(*filters).dct if len(filters) > 1 else filters[0].dct
500-
)
501-
502-
response_json = self.client.get(endpoint, filters=parsed_filters)
503-
504-
if "data" not in response_json:
505-
raise UnexpectedResponseError(
506-
"Unexpected response when retrieving alert channel alerts!",
507-
json=response_json,
508-
)
509-
510-
# Create AlertDefinition objects with proper parent_id (service_type)
511-
result = [
512-
AlertDefinition.make_instance(
513-
obj["id"],
514-
self.client,
515-
parent_id=obj["service_type"],
516-
json=obj,
517-
)
518-
for obj in response_json.get("data", [])
519-
if "id" in obj and "service_type" in obj
520-
]
521-
522-
return PaginatedList(
523-
self.client,
524-
endpoint[1:],
525-
page=result,
526-
max_pages=response_json.get("pages", 1),
527-
total_items=response_json.get("results", len(result)),
528-
parent_id=None,
529-
filters=parsed_filters,
530-
)
531-
532619
def destinations(self, *filters) -> PaginatedList:
533620
"""
534621
List available logs destinations.

0 commit comments

Comments
 (0)