200 lines
5.9 KiB
Python
200 lines
5.9 KiB
Python
"""
|
|
Webhook notifications module
|
|
|
|
Send deployment event notifications with retry logic
|
|
"""
|
|
|
|
import logging
|
|
import time
|
|
from dataclasses import asdict, dataclass
|
|
from datetime import datetime
|
|
from typing import Any, Dict, Optional
|
|
|
|
import requests
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class WebhookEvent:
|
|
"""Webhook event data"""
|
|
event_type: str # deployment_started, deployment_success, etc.
|
|
timestamp: str
|
|
subdomain: str
|
|
url: str
|
|
message: str
|
|
metadata: Dict[str, Any]
|
|
|
|
|
|
class WebhookNotifier:
|
|
"""Send webhook notifications with retry logic"""
|
|
|
|
def __init__(
|
|
self,
|
|
webhook_url: Optional[str],
|
|
timeout: int,
|
|
max_retries: int
|
|
):
|
|
"""
|
|
Initialize webhook notifier
|
|
|
|
Args:
|
|
webhook_url: Webhook URL to send notifications to (None to disable)
|
|
timeout: Request timeout in seconds
|
|
max_retries: Maximum number of retry attempts
|
|
"""
|
|
self._webhook_url = webhook_url
|
|
self._timeout = timeout
|
|
self._max_retries = max_retries
|
|
self._logger = logging.getLogger(f"{__name__}.WebhookNotifier")
|
|
|
|
if not webhook_url:
|
|
self._logger.debug("Webhook notifications disabled (no URL configured)")
|
|
|
|
def notify(self, event: WebhookEvent) -> None:
|
|
"""
|
|
Send webhook notification with retry
|
|
|
|
Args:
|
|
event: WebhookEvent to send
|
|
|
|
Note:
|
|
Failures are logged but don't raise exceptions to avoid
|
|
failing deployments due to webhook issues
|
|
"""
|
|
if not self._webhook_url:
|
|
return
|
|
|
|
payload = asdict(event)
|
|
|
|
self._logger.debug(f"Sending webhook: {event.event_type}")
|
|
|
|
for attempt in range(1, self._max_retries + 1):
|
|
try:
|
|
response = requests.post(
|
|
self._webhook_url,
|
|
json=payload,
|
|
timeout=self._timeout
|
|
)
|
|
response.raise_for_status()
|
|
|
|
self._logger.debug(
|
|
f"Webhook sent successfully: {event.event_type} "
|
|
f"(attempt {attempt})"
|
|
)
|
|
return
|
|
|
|
except requests.RequestException as e:
|
|
self._logger.warning(
|
|
f"Webhook delivery failed (attempt {attempt}/{self._max_retries}): {e}"
|
|
)
|
|
|
|
if attempt < self._max_retries:
|
|
# Exponential backoff: 1s, 2s, 4s, etc.
|
|
backoff = 2 ** (attempt - 1)
|
|
self._logger.debug(f"Retrying in {backoff}s...")
|
|
time.sleep(backoff)
|
|
|
|
self._logger.error(
|
|
f"Failed to deliver webhook after {self._max_retries} attempts: "
|
|
f"{event.event_type}"
|
|
)
|
|
|
|
def deployment_started(self, subdomain: str, url: str) -> None:
|
|
"""
|
|
Convenience method for deployment_started event
|
|
|
|
Args:
|
|
subdomain: Subdomain being deployed
|
|
url: Full URL being deployed
|
|
"""
|
|
event = WebhookEvent(
|
|
event_type="deployment_started",
|
|
timestamp=datetime.utcnow().isoformat() + "Z",
|
|
subdomain=subdomain,
|
|
url=url,
|
|
message=f"Deployment started for {url}",
|
|
metadata={}
|
|
)
|
|
self.notify(event)
|
|
|
|
def deployment_success(
|
|
self,
|
|
subdomain: str,
|
|
url: str,
|
|
duration: float
|
|
) -> None:
|
|
"""
|
|
Convenience method for deployment_success event
|
|
|
|
Args:
|
|
subdomain: Subdomain that was deployed
|
|
url: Full URL that was deployed
|
|
duration: Deployment duration in seconds
|
|
"""
|
|
event = WebhookEvent(
|
|
event_type="deployment_success",
|
|
timestamp=datetime.utcnow().isoformat() + "Z",
|
|
subdomain=subdomain,
|
|
url=url,
|
|
message=f"Deployment successful for {url}",
|
|
metadata={"duration": round(duration, 2)}
|
|
)
|
|
self.notify(event)
|
|
|
|
def deployment_failed(self, subdomain: str, error: str, url: str = "") -> None:
|
|
"""
|
|
Convenience method for deployment_failed event
|
|
|
|
Args:
|
|
subdomain: Subdomain that failed to deploy
|
|
error: Error message
|
|
url: Full URL (may be empty if deployment failed early)
|
|
"""
|
|
event = WebhookEvent(
|
|
event_type="deployment_failed",
|
|
timestamp=datetime.utcnow().isoformat() + "Z",
|
|
subdomain=subdomain,
|
|
url=url,
|
|
message=f"Deployment failed: {error}",
|
|
metadata={"error": error}
|
|
)
|
|
self.notify(event)
|
|
|
|
def dns_added(self, hostname: str, ip: str) -> None:
|
|
"""
|
|
Convenience method for dns_added event
|
|
|
|
Args:
|
|
hostname: Hostname that was added to DNS
|
|
ip: IP address the hostname points to
|
|
"""
|
|
event = WebhookEvent(
|
|
event_type="dns_added",
|
|
timestamp=datetime.utcnow().isoformat() + "Z",
|
|
subdomain=hostname.split('.')[0], # Extract subdomain
|
|
url=hostname,
|
|
message=f"DNS record added for {hostname}",
|
|
metadata={"ip": ip}
|
|
)
|
|
self.notify(event)
|
|
|
|
def health_check_passed(self, url: str, duration: float) -> None:
|
|
"""
|
|
Convenience method for health_check_passed event
|
|
|
|
Args:
|
|
url: URL that passed health check
|
|
duration: Time taken for health check in seconds
|
|
"""
|
|
event = WebhookEvent(
|
|
event_type="health_check_passed",
|
|
timestamp=datetime.utcnow().isoformat() + "Z",
|
|
subdomain=url.split('.')[0].replace('https://', '').replace('http://', ''),
|
|
url=url,
|
|
message=f"Health check passed for {url}",
|
|
metadata={"duration": round(duration, 2)}
|
|
)
|
|
self.notify(event)
|