feat: add dry-run mode, GFS retention policies, and notifications
- Add --dry-run/-n flag for backup commands with comprehensive preflight checks - Database connectivity validation - Required tools availability check - Storage target and permissions verification - Backup size estimation - Encryption and cloud storage configuration validation - Implement GFS (Grandfather-Father-Son) retention policies - Daily/Weekly/Monthly/Yearly tier classification - Configurable retention counts per tier - Custom weekly day and monthly day settings - ISO week handling for proper week boundaries - Add notification system with SMTP and webhook support - SMTP email notifications with TLS/STARTTLS - Webhook HTTP notifications with HMAC-SHA256 signing - Slack-compatible webhook payload format - Event types: backup/restore started/completed/failed, cleanup, verify, PITR - Configurable severity levels and retry logic - Update README.md with documentation for all new features
This commit is contained in:
256
internal/notify/manager.go
Normal file
256
internal/notify/manager.go
Normal file
@@ -0,0 +1,256 @@
|
||||
// Package notify - Notification manager for fan-out to multiple backends
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Manager manages multiple notification backends
|
||||
type Manager struct {
|
||||
config Config
|
||||
notifiers []Notifier
|
||||
mu sync.RWMutex
|
||||
hostname string
|
||||
}
|
||||
|
||||
// NewManager creates a new notification manager with configured backends
|
||||
func NewManager(config Config) *Manager {
|
||||
hostname, _ := os.Hostname()
|
||||
|
||||
m := &Manager{
|
||||
config: config,
|
||||
notifiers: make([]Notifier, 0),
|
||||
hostname: hostname,
|
||||
}
|
||||
|
||||
// Initialize enabled backends
|
||||
if config.SMTPEnabled {
|
||||
m.notifiers = append(m.notifiers, NewSMTPNotifier(config))
|
||||
}
|
||||
|
||||
if config.WebhookEnabled {
|
||||
m.notifiers = append(m.notifiers, NewWebhookNotifier(config))
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// AddNotifier adds a custom notifier to the manager
|
||||
func (m *Manager) AddNotifier(n Notifier) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.notifiers = append(m.notifiers, n)
|
||||
}
|
||||
|
||||
// Notify sends an event to all enabled notification backends
|
||||
// This is a non-blocking operation that runs in a goroutine
|
||||
func (m *Manager) Notify(event *Event) {
|
||||
go m.NotifySync(context.Background(), event)
|
||||
}
|
||||
|
||||
// NotifySync sends an event synchronously to all enabled backends
|
||||
func (m *Manager) NotifySync(ctx context.Context, event *Event) error {
|
||||
// Add hostname if not set
|
||||
if event.Hostname == "" && m.hostname != "" {
|
||||
event.Hostname = m.hostname
|
||||
}
|
||||
|
||||
// Check if we should send based on event type/severity
|
||||
if !m.shouldSend(event) {
|
||||
return nil
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
notifiers := make([]Notifier, len(m.notifiers))
|
||||
copy(notifiers, m.notifiers)
|
||||
m.mu.RUnlock()
|
||||
|
||||
var errors []error
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, n := range notifiers {
|
||||
if !n.IsEnabled() {
|
||||
continue
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func(notifier Notifier) {
|
||||
defer wg.Done()
|
||||
if err := notifier.Send(ctx, event); err != nil {
|
||||
errors = append(errors, fmt.Errorf("%s: %w", notifier.Name(), err))
|
||||
}
|
||||
}(n)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if len(errors) > 0 {
|
||||
return fmt.Errorf("notification errors: %v", errors)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// shouldSend determines if an event should be sent based on configuration
|
||||
func (m *Manager) shouldSend(event *Event) bool {
|
||||
// Check minimum severity
|
||||
if !m.meetsSeverity(event.Severity) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check event type filters
|
||||
switch event.Type {
|
||||
case EventBackupCompleted, EventRestoreCompleted, EventCleanupCompleted, EventVerifyCompleted:
|
||||
return m.config.OnSuccess
|
||||
case EventBackupFailed, EventRestoreFailed, EventVerifyFailed:
|
||||
return m.config.OnFailure
|
||||
case EventBackupStarted, EventRestoreStarted:
|
||||
return m.config.OnSuccess
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// meetsSeverity checks if event severity meets minimum threshold
|
||||
func (m *Manager) meetsSeverity(severity Severity) bool {
|
||||
severityOrder := map[Severity]int{
|
||||
SeverityInfo: 0,
|
||||
SeverityWarning: 1,
|
||||
SeverityError: 2,
|
||||
SeverityCritical: 3,
|
||||
}
|
||||
|
||||
eventLevel, ok := severityOrder[severity]
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
minLevel, ok := severityOrder[m.config.MinSeverity]
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
return eventLevel >= minLevel
|
||||
}
|
||||
|
||||
// HasEnabledNotifiers returns true if at least one notifier is enabled
|
||||
func (m *Manager) HasEnabledNotifiers() bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, n := range m.notifiers {
|
||||
if n.IsEnabled() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// EnabledNotifiers returns the names of all enabled notifiers
|
||||
func (m *Manager) EnabledNotifiers() []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
names := make([]string, 0)
|
||||
for _, n := range m.notifiers {
|
||||
if n.IsEnabled() {
|
||||
names = append(names, n.Name())
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// BackupStarted sends a backup started notification
|
||||
func (m *Manager) BackupStarted(database string) {
|
||||
event := NewEvent(EventBackupStarted, SeverityInfo, fmt.Sprintf("Starting backup of database '%s'", database)).
|
||||
WithDatabase(database)
|
||||
m.Notify(event)
|
||||
}
|
||||
|
||||
// BackupCompleted sends a backup completed notification
|
||||
func (m *Manager) BackupCompleted(database, backupFile string, size int64, duration interface{}) {
|
||||
event := NewEvent(EventBackupCompleted, SeverityInfo, fmt.Sprintf("Backup of database '%s' completed successfully", database)).
|
||||
WithDatabase(database).
|
||||
WithBackupInfo(backupFile, size)
|
||||
|
||||
if d, ok := duration.(interface{ Seconds() float64 }); ok {
|
||||
event.WithDetail("duration_seconds", fmt.Sprintf("%.2f", d.Seconds()))
|
||||
}
|
||||
|
||||
m.Notify(event)
|
||||
}
|
||||
|
||||
// BackupFailed sends a backup failed notification
|
||||
func (m *Manager) BackupFailed(database string, err error) {
|
||||
event := NewEvent(EventBackupFailed, SeverityError, fmt.Sprintf("Backup of database '%s' failed", database)).
|
||||
WithDatabase(database).
|
||||
WithError(err)
|
||||
m.Notify(event)
|
||||
}
|
||||
|
||||
// RestoreStarted sends a restore started notification
|
||||
func (m *Manager) RestoreStarted(database, backupFile string) {
|
||||
event := NewEvent(EventRestoreStarted, SeverityInfo, fmt.Sprintf("Starting restore of database '%s' from '%s'", database, backupFile)).
|
||||
WithDatabase(database).
|
||||
WithBackupInfo(backupFile, 0)
|
||||
m.Notify(event)
|
||||
}
|
||||
|
||||
// RestoreCompleted sends a restore completed notification
|
||||
func (m *Manager) RestoreCompleted(database, backupFile string, duration interface{}) {
|
||||
event := NewEvent(EventRestoreCompleted, SeverityInfo, fmt.Sprintf("Restore of database '%s' completed successfully", database)).
|
||||
WithDatabase(database).
|
||||
WithBackupInfo(backupFile, 0)
|
||||
|
||||
if d, ok := duration.(interface{ Seconds() float64 }); ok {
|
||||
event.WithDetail("duration_seconds", fmt.Sprintf("%.2f", d.Seconds()))
|
||||
}
|
||||
|
||||
m.Notify(event)
|
||||
}
|
||||
|
||||
// RestoreFailed sends a restore failed notification
|
||||
func (m *Manager) RestoreFailed(database string, err error) {
|
||||
event := NewEvent(EventRestoreFailed, SeverityError, fmt.Sprintf("Restore of database '%s' failed", database)).
|
||||
WithDatabase(database).
|
||||
WithError(err)
|
||||
m.Notify(event)
|
||||
}
|
||||
|
||||
// CleanupCompleted sends a cleanup completed notification
|
||||
func (m *Manager) CleanupCompleted(directory string, deleted int, spaceFreed int64) {
|
||||
event := NewEvent(EventCleanupCompleted, SeverityInfo, fmt.Sprintf("Cleanup completed: %d backups deleted", deleted)).
|
||||
WithDetail("directory", directory).
|
||||
WithDetail("space_freed", formatBytes(spaceFreed))
|
||||
m.Notify(event)
|
||||
}
|
||||
|
||||
// VerifyCompleted sends a verification completed notification
|
||||
func (m *Manager) VerifyCompleted(backupFile string, isValid bool) {
|
||||
if isValid {
|
||||
event := NewEvent(EventVerifyCompleted, SeverityInfo, "Backup verification passed").
|
||||
WithBackupInfo(backupFile, 0)
|
||||
m.Notify(event)
|
||||
} else {
|
||||
event := NewEvent(EventVerifyFailed, SeverityError, "Backup verification failed").
|
||||
WithBackupInfo(backupFile, 0)
|
||||
m.Notify(event)
|
||||
}
|
||||
}
|
||||
|
||||
// PITRRecovery sends a PITR recovery notification
|
||||
func (m *Manager) PITRRecovery(database, targetTime string) {
|
||||
event := NewEvent(EventPITRRecovery, SeverityInfo, fmt.Sprintf("Point-in-time recovery initiated for '%s' to %s", database, targetTime)).
|
||||
WithDatabase(database).
|
||||
WithDetail("target_time", targetTime)
|
||||
m.Notify(event)
|
||||
}
|
||||
|
||||
// NullManager returns a no-op notification manager
|
||||
func NullManager() *Manager {
|
||||
return &Manager{
|
||||
notifiers: make([]Notifier, 0),
|
||||
}
|
||||
}
|
||||
260
internal/notify/notify.go
Normal file
260
internal/notify/notify.go
Normal file
@@ -0,0 +1,260 @@
|
||||
// Package notify provides notification capabilities for backup events
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EventType represents the type of notification event
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventBackupStarted EventType = "backup_started"
|
||||
EventBackupCompleted EventType = "backup_completed"
|
||||
EventBackupFailed EventType = "backup_failed"
|
||||
EventRestoreStarted EventType = "restore_started"
|
||||
EventRestoreCompleted EventType = "restore_completed"
|
||||
EventRestoreFailed EventType = "restore_failed"
|
||||
EventCleanupCompleted EventType = "cleanup_completed"
|
||||
EventVerifyCompleted EventType = "verify_completed"
|
||||
EventVerifyFailed EventType = "verify_failed"
|
||||
EventPITRRecovery EventType = "pitr_recovery"
|
||||
)
|
||||
|
||||
// Severity represents the severity level of a notification
|
||||
type Severity string
|
||||
|
||||
const (
|
||||
SeverityInfo Severity = "info"
|
||||
SeverityWarning Severity = "warning"
|
||||
SeverityError Severity = "error"
|
||||
SeverityCritical Severity = "critical"
|
||||
)
|
||||
|
||||
// Event represents a notification event
|
||||
type Event struct {
|
||||
Type EventType `json:"type"`
|
||||
Severity Severity `json:"severity"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Database string `json:"database,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]string `json:"details,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Duration time.Duration `json:"duration,omitempty"`
|
||||
BackupFile string `json:"backup_file,omitempty"`
|
||||
BackupSize int64 `json:"backup_size,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
}
|
||||
|
||||
// NewEvent creates a new notification event
|
||||
func NewEvent(eventType EventType, severity Severity, message string) *Event {
|
||||
return &Event{
|
||||
Type: eventType,
|
||||
Severity: severity,
|
||||
Timestamp: time.Now(),
|
||||
Message: message,
|
||||
Details: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// WithDatabase adds database name to the event
|
||||
func (e *Event) WithDatabase(db string) *Event {
|
||||
e.Database = db
|
||||
return e
|
||||
}
|
||||
|
||||
// WithError adds error information to the event
|
||||
func (e *Event) WithError(err error) *Event {
|
||||
if err != nil {
|
||||
e.Error = err.Error()
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// WithDuration adds duration to the event
|
||||
func (e *Event) WithDuration(d time.Duration) *Event {
|
||||
e.Duration = d
|
||||
return e
|
||||
}
|
||||
|
||||
// WithBackupInfo adds backup file and size information
|
||||
func (e *Event) WithBackupInfo(file string, size int64) *Event {
|
||||
e.BackupFile = file
|
||||
e.BackupSize = size
|
||||
return e
|
||||
}
|
||||
|
||||
// WithHostname adds hostname to the event
|
||||
func (e *Event) WithHostname(hostname string) *Event {
|
||||
e.Hostname = hostname
|
||||
return e
|
||||
}
|
||||
|
||||
// WithDetail adds a custom detail to the event
|
||||
func (e *Event) WithDetail(key, value string) *Event {
|
||||
if e.Details == nil {
|
||||
e.Details = make(map[string]string)
|
||||
}
|
||||
e.Details[key] = value
|
||||
return e
|
||||
}
|
||||
|
||||
// Notifier is the interface that all notification backends must implement
|
||||
type Notifier interface {
|
||||
// Name returns the name of the notifier (e.g., "smtp", "webhook")
|
||||
Name() string
|
||||
// Send sends a notification event
|
||||
Send(ctx context.Context, event *Event) error
|
||||
// IsEnabled returns whether the notifier is configured and enabled
|
||||
IsEnabled() bool
|
||||
}
|
||||
|
||||
// Config holds configuration for all notification backends
|
||||
type Config struct {
|
||||
// SMTP configuration
|
||||
SMTPEnabled bool
|
||||
SMTPHost string
|
||||
SMTPPort int
|
||||
SMTPUser string
|
||||
SMTPPassword string
|
||||
SMTPFrom string
|
||||
SMTPTo []string
|
||||
SMTPTLS bool
|
||||
SMTPStartTLS bool
|
||||
|
||||
// Webhook configuration
|
||||
WebhookEnabled bool
|
||||
WebhookURL string
|
||||
WebhookMethod string // GET, POST
|
||||
WebhookHeaders map[string]string
|
||||
WebhookSecret string // For signing payloads
|
||||
|
||||
// General settings
|
||||
OnSuccess bool // Send notifications on successful operations
|
||||
OnFailure bool // Send notifications on failed operations
|
||||
OnWarning bool // Send notifications on warnings
|
||||
MinSeverity Severity
|
||||
Retries int // Number of retry attempts
|
||||
RetryDelay time.Duration // Delay between retries
|
||||
}
|
||||
|
||||
// DefaultConfig returns a configuration with sensible defaults
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
SMTPPort: 587,
|
||||
SMTPTLS: false,
|
||||
SMTPStartTLS: true,
|
||||
WebhookMethod: "POST",
|
||||
OnSuccess: true,
|
||||
OnFailure: true,
|
||||
OnWarning: true,
|
||||
MinSeverity: SeverityInfo,
|
||||
Retries: 3,
|
||||
RetryDelay: 5 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// FormatEventSubject generates a subject line for notifications
|
||||
func FormatEventSubject(event *Event) string {
|
||||
icon := "ℹ️"
|
||||
switch event.Severity {
|
||||
case SeverityWarning:
|
||||
icon = "⚠️"
|
||||
case SeverityError, SeverityCritical:
|
||||
icon = "❌"
|
||||
}
|
||||
|
||||
verb := "Event"
|
||||
switch event.Type {
|
||||
case EventBackupStarted:
|
||||
verb = "Backup Started"
|
||||
icon = "🔄"
|
||||
case EventBackupCompleted:
|
||||
verb = "Backup Completed"
|
||||
icon = "✅"
|
||||
case EventBackupFailed:
|
||||
verb = "Backup Failed"
|
||||
icon = "❌"
|
||||
case EventRestoreStarted:
|
||||
verb = "Restore Started"
|
||||
icon = "🔄"
|
||||
case EventRestoreCompleted:
|
||||
verb = "Restore Completed"
|
||||
icon = "✅"
|
||||
case EventRestoreFailed:
|
||||
verb = "Restore Failed"
|
||||
icon = "❌"
|
||||
case EventCleanupCompleted:
|
||||
verb = "Cleanup Completed"
|
||||
icon = "🗑️"
|
||||
case EventVerifyCompleted:
|
||||
verb = "Verification Passed"
|
||||
icon = "✅"
|
||||
case EventVerifyFailed:
|
||||
verb = "Verification Failed"
|
||||
icon = "❌"
|
||||
case EventPITRRecovery:
|
||||
verb = "PITR Recovery"
|
||||
icon = "⏪"
|
||||
}
|
||||
|
||||
if event.Database != "" {
|
||||
return fmt.Sprintf("%s [dbbackup] %s: %s", icon, verb, event.Database)
|
||||
}
|
||||
return fmt.Sprintf("%s [dbbackup] %s", icon, verb)
|
||||
}
|
||||
|
||||
// FormatEventBody generates a message body for notifications
|
||||
func FormatEventBody(event *Event) string {
|
||||
body := fmt.Sprintf("%s\n\n", event.Message)
|
||||
body += fmt.Sprintf("Time: %s\n", event.Timestamp.Format(time.RFC3339))
|
||||
|
||||
if event.Database != "" {
|
||||
body += fmt.Sprintf("Database: %s\n", event.Database)
|
||||
}
|
||||
|
||||
if event.Hostname != "" {
|
||||
body += fmt.Sprintf("Host: %s\n", event.Hostname)
|
||||
}
|
||||
|
||||
if event.Duration > 0 {
|
||||
body += fmt.Sprintf("Duration: %s\n", event.Duration.Round(time.Second))
|
||||
}
|
||||
|
||||
if event.BackupFile != "" {
|
||||
body += fmt.Sprintf("Backup File: %s\n", event.BackupFile)
|
||||
}
|
||||
|
||||
if event.BackupSize > 0 {
|
||||
body += fmt.Sprintf("Backup Size: %s\n", formatBytes(event.BackupSize))
|
||||
}
|
||||
|
||||
if event.Error != "" {
|
||||
body += fmt.Sprintf("\nError: %s\n", event.Error)
|
||||
}
|
||||
|
||||
if len(event.Details) > 0 {
|
||||
body += "\nDetails:\n"
|
||||
for k, v := range event.Details {
|
||||
body += fmt.Sprintf(" %s: %s\n", k, v)
|
||||
}
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
// formatBytes formats bytes as human-readable string
|
||||
func formatBytes(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
279
internal/notify/notify_test.go
Normal file
279
internal/notify/notify_test.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewEvent(t *testing.T) {
|
||||
event := NewEvent(EventBackupCompleted, SeverityInfo, "Backup completed")
|
||||
|
||||
if event.Type != EventBackupCompleted {
|
||||
t.Errorf("Type = %v, expected %v", event.Type, EventBackupCompleted)
|
||||
}
|
||||
|
||||
if event.Severity != SeverityInfo {
|
||||
t.Errorf("Severity = %v, expected %v", event.Severity, SeverityInfo)
|
||||
}
|
||||
|
||||
if event.Message != "Backup completed" {
|
||||
t.Errorf("Message = %q, expected %q", event.Message, "Backup completed")
|
||||
}
|
||||
|
||||
if event.Timestamp.IsZero() {
|
||||
t.Error("Timestamp should not be zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventChaining(t *testing.T) {
|
||||
event := NewEvent(EventBackupCompleted, SeverityInfo, "Backup completed").
|
||||
WithDatabase("testdb").
|
||||
WithBackupInfo("/backups/test.dump", 1024).
|
||||
WithHostname("server1").
|
||||
WithDetail("custom", "value")
|
||||
|
||||
if event.Database != "testdb" {
|
||||
t.Errorf("Database = %q, expected %q", event.Database, "testdb")
|
||||
}
|
||||
|
||||
if event.BackupFile != "/backups/test.dump" {
|
||||
t.Errorf("BackupFile = %q, expected %q", event.BackupFile, "/backups/test.dump")
|
||||
}
|
||||
|
||||
if event.BackupSize != 1024 {
|
||||
t.Errorf("BackupSize = %d, expected %d", event.BackupSize, 1024)
|
||||
}
|
||||
|
||||
if event.Hostname != "server1" {
|
||||
t.Errorf("Hostname = %q, expected %q", event.Hostname, "server1")
|
||||
}
|
||||
|
||||
if event.Details["custom"] != "value" {
|
||||
t.Errorf("Details[custom] = %q, expected %q", event.Details["custom"], "value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatEventSubject(t *testing.T) {
|
||||
tests := []struct {
|
||||
eventType EventType
|
||||
database string
|
||||
contains string
|
||||
}{
|
||||
{EventBackupCompleted, "testdb", "Backup Completed"},
|
||||
{EventBackupFailed, "testdb", "Backup Failed"},
|
||||
{EventRestoreCompleted, "", "Restore Completed"},
|
||||
{EventCleanupCompleted, "", "Cleanup Completed"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
event := NewEvent(tc.eventType, SeverityInfo, "test")
|
||||
if tc.database != "" {
|
||||
event.WithDatabase(tc.database)
|
||||
}
|
||||
|
||||
subject := FormatEventSubject(event)
|
||||
if subject == "" {
|
||||
t.Errorf("FormatEventSubject() returned empty string for %v", tc.eventType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatEventBody(t *testing.T) {
|
||||
event := NewEvent(EventBackupCompleted, SeverityInfo, "Backup completed").
|
||||
WithDatabase("testdb").
|
||||
WithBackupInfo("/backups/test.dump", 1024).
|
||||
WithHostname("server1")
|
||||
|
||||
body := FormatEventBody(event)
|
||||
|
||||
if body == "" {
|
||||
t.Error("FormatEventBody() returned empty string")
|
||||
}
|
||||
|
||||
// Should contain message
|
||||
if body == "" || len(body) < 10 {
|
||||
t.Error("Body should contain event information")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfig(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
|
||||
if config.SMTPPort != 587 {
|
||||
t.Errorf("SMTPPort = %d, expected 587", config.SMTPPort)
|
||||
}
|
||||
|
||||
if !config.SMTPStartTLS {
|
||||
t.Error("SMTPStartTLS should be true by default")
|
||||
}
|
||||
|
||||
if config.WebhookMethod != "POST" {
|
||||
t.Errorf("WebhookMethod = %q, expected POST", config.WebhookMethod)
|
||||
}
|
||||
|
||||
if !config.OnSuccess {
|
||||
t.Error("OnSuccess should be true by default")
|
||||
}
|
||||
|
||||
if !config.OnFailure {
|
||||
t.Error("OnFailure should be true by default")
|
||||
}
|
||||
|
||||
if config.Retries != 3 {
|
||||
t.Errorf("Retries = %d, expected 3", config.Retries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookNotifierSend(t *testing.T) {
|
||||
var receivedPayload WebhookPayload
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
t.Errorf("Method = %q, expected POST", r.Method)
|
||||
}
|
||||
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("Content-Type = %q, expected application/json", r.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
if err := decoder.Decode(&receivedPayload); err != nil {
|
||||
t.Errorf("Failed to decode payload: %v", err)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := DefaultConfig()
|
||||
config.WebhookEnabled = true
|
||||
config.WebhookURL = server.URL
|
||||
|
||||
notifier := NewWebhookNotifier(config)
|
||||
|
||||
event := NewEvent(EventBackupCompleted, SeverityInfo, "Backup completed").
|
||||
WithDatabase("testdb")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := notifier.Send(ctx, event)
|
||||
if err != nil {
|
||||
t.Errorf("Send() error = %v", err)
|
||||
}
|
||||
|
||||
if receivedPayload.Event.Database != "testdb" {
|
||||
t.Errorf("Received database = %q, expected testdb", receivedPayload.Event.Database)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookNotifierDisabled(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
config.WebhookEnabled = false
|
||||
|
||||
notifier := NewWebhookNotifier(config)
|
||||
|
||||
if notifier.IsEnabled() {
|
||||
t.Error("Notifier should be disabled")
|
||||
}
|
||||
|
||||
event := NewEvent(EventBackupCompleted, SeverityInfo, "test")
|
||||
err := notifier.Send(context.Background(), event)
|
||||
if err != nil {
|
||||
t.Errorf("Send() should not error when disabled: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPNotifierDisabled(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
config.SMTPEnabled = false
|
||||
|
||||
notifier := NewSMTPNotifier(config)
|
||||
|
||||
if notifier.IsEnabled() {
|
||||
t.Error("Notifier should be disabled")
|
||||
}
|
||||
|
||||
event := NewEvent(EventBackupCompleted, SeverityInfo, "test")
|
||||
err := notifier.Send(context.Background(), event)
|
||||
if err != nil {
|
||||
t.Errorf("Send() should not error when disabled: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerNoNotifiers(t *testing.T) {
|
||||
config := DefaultConfig()
|
||||
config.SMTPEnabled = false
|
||||
config.WebhookEnabled = false
|
||||
|
||||
manager := NewManager(config)
|
||||
|
||||
if manager.HasEnabledNotifiers() {
|
||||
t.Error("Manager should have no enabled notifiers")
|
||||
}
|
||||
|
||||
names := manager.EnabledNotifiers()
|
||||
if len(names) != 0 {
|
||||
t.Errorf("EnabledNotifiers() = %v, expected empty", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerWithWebhook(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := DefaultConfig()
|
||||
config.WebhookEnabled = true
|
||||
config.WebhookURL = server.URL
|
||||
|
||||
manager := NewManager(config)
|
||||
|
||||
if !manager.HasEnabledNotifiers() {
|
||||
t.Error("Manager should have enabled notifiers")
|
||||
}
|
||||
|
||||
names := manager.EnabledNotifiers()
|
||||
if len(names) != 1 || names[0] != "webhook" {
|
||||
t.Errorf("EnabledNotifiers() = %v, expected [webhook]", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNullManager(t *testing.T) {
|
||||
manager := NullManager()
|
||||
|
||||
if manager.HasEnabledNotifiers() {
|
||||
t.Error("NullManager should have no enabled notifiers")
|
||||
}
|
||||
|
||||
// Should not panic
|
||||
manager.BackupStarted("testdb")
|
||||
manager.BackupCompleted("testdb", "/backup.dump", 1024, nil)
|
||||
manager.BackupFailed("testdb", nil)
|
||||
}
|
||||
|
||||
func TestFormatBytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
input int64
|
||||
expected string
|
||||
}{
|
||||
{0, "0 B"},
|
||||
{500, "500 B"},
|
||||
{1024, "1.0 KB"},
|
||||
{1536, "1.5 KB"},
|
||||
{1048576, "1.0 MB"},
|
||||
{1073741824, "1.0 GB"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
result := formatBytes(tc.input)
|
||||
if result != tc.expected {
|
||||
t.Errorf("formatBytes(%d) = %q, expected %q", tc.input, result, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
179
internal/notify/smtp.go
Normal file
179
internal/notify/smtp.go
Normal file
@@ -0,0 +1,179 @@
|
||||
// Package notify - SMTP email notifications
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SMTPNotifier sends notifications via email
|
||||
type SMTPNotifier struct {
|
||||
config Config
|
||||
}
|
||||
|
||||
// NewSMTPNotifier creates a new SMTP notifier
|
||||
func NewSMTPNotifier(config Config) *SMTPNotifier {
|
||||
return &SMTPNotifier{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the notifier name
|
||||
func (s *SMTPNotifier) Name() string {
|
||||
return "smtp"
|
||||
}
|
||||
|
||||
// IsEnabled returns whether SMTP notifications are enabled
|
||||
func (s *SMTPNotifier) IsEnabled() bool {
|
||||
return s.config.SMTPEnabled && s.config.SMTPHost != "" && len(s.config.SMTPTo) > 0
|
||||
}
|
||||
|
||||
// Send sends an email notification
|
||||
func (s *SMTPNotifier) Send(ctx context.Context, event *Event) error {
|
||||
if !s.IsEnabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build email
|
||||
subject := FormatEventSubject(event)
|
||||
body := FormatEventBody(event)
|
||||
|
||||
// Build headers
|
||||
headers := make(map[string]string)
|
||||
headers["From"] = s.config.SMTPFrom
|
||||
headers["To"] = strings.Join(s.config.SMTPTo, ", ")
|
||||
headers["Subject"] = subject
|
||||
headers["MIME-Version"] = "1.0"
|
||||
headers["Content-Type"] = "text/plain; charset=UTF-8"
|
||||
headers["Date"] = time.Now().Format(time.RFC1123Z)
|
||||
headers["X-Priority"] = s.getPriority(event.Severity)
|
||||
|
||||
// Build message
|
||||
var msg strings.Builder
|
||||
for k, v := range headers {
|
||||
msg.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
|
||||
}
|
||||
msg.WriteString("\r\n")
|
||||
msg.WriteString(body)
|
||||
|
||||
// Send with retries
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= s.config.Retries; attempt++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
if attempt > 0 {
|
||||
time.Sleep(s.config.RetryDelay)
|
||||
}
|
||||
|
||||
err := s.sendMail(ctx, msg.String())
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
return fmt.Errorf("smtp: failed after %d attempts: %w", s.config.Retries+1, lastErr)
|
||||
}
|
||||
|
||||
// sendMail sends the email message
|
||||
func (s *SMTPNotifier) sendMail(ctx context.Context, message string) error {
|
||||
addr := fmt.Sprintf("%s:%d", s.config.SMTPHost, s.config.SMTPPort)
|
||||
|
||||
// Create connection with timeout
|
||||
dialer := &net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
var conn net.Conn
|
||||
var err error
|
||||
|
||||
if s.config.SMTPTLS {
|
||||
// Direct TLS connection (port 465)
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: s.config.SMTPHost,
|
||||
}
|
||||
conn, err = tls.DialWithDialer(dialer, "tcp", addr, tlsConfig)
|
||||
} else {
|
||||
conn, err = dialer.DialContext(ctx, "tcp", addr)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial failed: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Create SMTP client
|
||||
client, err := smtp.NewClient(conn, s.config.SMTPHost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp client creation failed: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// STARTTLS if needed (and not already using TLS)
|
||||
if s.config.SMTPStartTLS && !s.config.SMTPTLS {
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: s.config.SMTPHost,
|
||||
}
|
||||
if err = client.StartTLS(tlsConfig); err != nil {
|
||||
return fmt.Errorf("starttls failed: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate if credentials provided
|
||||
if s.config.SMTPUser != "" && s.config.SMTPPassword != "" {
|
||||
auth := smtp.PlainAuth("", s.config.SMTPUser, s.config.SMTPPassword, s.config.SMTPHost)
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("auth failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Set sender
|
||||
if err = client.Mail(s.config.SMTPFrom); err != nil {
|
||||
return fmt.Errorf("mail from failed: %w", err)
|
||||
}
|
||||
|
||||
// Set recipients
|
||||
for _, to := range s.config.SMTPTo {
|
||||
if err = client.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("rcpt to failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Send message body
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("data command failed: %w", err)
|
||||
}
|
||||
defer w.Close()
|
||||
|
||||
_, err = w.Write([]byte(message))
|
||||
if err != nil {
|
||||
return fmt.Errorf("write failed: %w", err)
|
||||
}
|
||||
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
// getPriority returns X-Priority header value based on severity
|
||||
func (s *SMTPNotifier) getPriority(severity Severity) string {
|
||||
switch severity {
|
||||
case SeverityCritical:
|
||||
return "1" // Highest
|
||||
case SeverityError:
|
||||
return "2" // High
|
||||
case SeverityWarning:
|
||||
return "3" // Normal
|
||||
default:
|
||||
return "3" // Normal
|
||||
}
|
||||
}
|
||||
337
internal/notify/webhook.go
Normal file
337
internal/notify/webhook.go
Normal file
@@ -0,0 +1,337 @@
|
||||
// Package notify - Webhook HTTP notifications
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WebhookNotifier sends notifications via HTTP webhooks
|
||||
type WebhookNotifier struct {
|
||||
config Config
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewWebhookNotifier creates a new Webhook notifier
|
||||
func NewWebhookNotifier(config Config) *WebhookNotifier {
|
||||
return &WebhookNotifier{
|
||||
config: config,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the notifier name
|
||||
func (w *WebhookNotifier) Name() string {
|
||||
return "webhook"
|
||||
}
|
||||
|
||||
// IsEnabled returns whether webhook notifications are enabled
|
||||
func (w *WebhookNotifier) IsEnabled() bool {
|
||||
return w.config.WebhookEnabled && w.config.WebhookURL != ""
|
||||
}
|
||||
|
||||
// WebhookPayload is the JSON payload sent to webhooks
|
||||
type WebhookPayload struct {
|
||||
Version string `json:"version"`
|
||||
Event *Event `json:"event"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Send sends a webhook notification
|
||||
func (w *WebhookNotifier) Send(ctx context.Context, event *Event) error {
|
||||
if !w.IsEnabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build payload
|
||||
payload := WebhookPayload{
|
||||
Version: "1.0",
|
||||
Event: event,
|
||||
Subject: FormatEventSubject(event),
|
||||
Body: FormatEventBody(event),
|
||||
Metadata: map[string]string{
|
||||
"source": "dbbackup",
|
||||
},
|
||||
}
|
||||
|
||||
// Marshal to JSON
|
||||
jsonBody, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webhook: failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
// Sign payload if secret is configured
|
||||
if w.config.WebhookSecret != "" {
|
||||
sig := w.signPayload(jsonBody)
|
||||
payload.Signature = sig
|
||||
// Re-marshal with signature
|
||||
jsonBody, _ = json.Marshal(payload)
|
||||
}
|
||||
|
||||
// Send with retries
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= w.config.Retries; attempt++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
if attempt > 0 {
|
||||
time.Sleep(w.config.RetryDelay)
|
||||
}
|
||||
|
||||
err := w.doRequest(ctx, jsonBody)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
return fmt.Errorf("webhook: failed after %d attempts: %w", w.config.Retries+1, lastErr)
|
||||
}
|
||||
|
||||
// doRequest performs the HTTP request
|
||||
func (w *WebhookNotifier) doRequest(ctx context.Context, body []byte) error {
|
||||
method := w.config.WebhookMethod
|
||||
if method == "" {
|
||||
method = "POST"
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, w.config.WebhookURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "dbbackup-notifier/1.0")
|
||||
|
||||
// Add custom headers
|
||||
for k, v := range w.config.WebhookHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
// Add signature header if secret is configured
|
||||
if w.config.WebhookSecret != "" {
|
||||
sig := w.signPayload(body)
|
||||
req.Header.Set("X-Webhook-Signature", "sha256="+sig)
|
||||
}
|
||||
|
||||
// Send request
|
||||
resp, err := w.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response body for error messages
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
|
||||
// Check status code
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// signPayload creates an HMAC-SHA256 signature
|
||||
func (w *WebhookNotifier) signPayload(payload []byte) string {
|
||||
mac := hmac.New(sha256.New, []byte(w.config.WebhookSecret))
|
||||
mac.Write(payload)
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// SlackPayload is a Slack-compatible webhook payload
|
||||
type SlackPayload struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
IconEmoji string `json:"icon_emoji,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
Attachments []Attachment `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
// Attachment is a Slack message attachment
|
||||
type Attachment struct {
|
||||
Color string `json:"color,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Fields []AttachmentField `json:"fields,omitempty"`
|
||||
Footer string `json:"footer,omitempty"`
|
||||
FooterIcon string `json:"footer_icon,omitempty"`
|
||||
Timestamp int64 `json:"ts,omitempty"`
|
||||
}
|
||||
|
||||
// AttachmentField is a field in a Slack attachment
|
||||
type AttachmentField struct {
|
||||
Title string `json:"title"`
|
||||
Value string `json:"value"`
|
||||
Short bool `json:"short"`
|
||||
}
|
||||
|
||||
// NewSlackNotifier creates a webhook notifier configured for Slack
|
||||
func NewSlackNotifier(webhookURL string, config Config) *SlackWebhookNotifier {
|
||||
return &SlackWebhookNotifier{
|
||||
webhookURL: webhookURL,
|
||||
config: config,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SlackWebhookNotifier sends Slack-formatted notifications
|
||||
type SlackWebhookNotifier struct {
|
||||
webhookURL string
|
||||
config Config
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// Name returns the notifier name
|
||||
func (s *SlackWebhookNotifier) Name() string {
|
||||
return "slack"
|
||||
}
|
||||
|
||||
// IsEnabled returns whether Slack notifications are enabled
|
||||
func (s *SlackWebhookNotifier) IsEnabled() bool {
|
||||
return s.webhookURL != ""
|
||||
}
|
||||
|
||||
// Send sends a Slack notification
|
||||
func (s *SlackWebhookNotifier) Send(ctx context.Context, event *Event) error {
|
||||
if !s.IsEnabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build Slack payload
|
||||
color := "#36a64f" // Green
|
||||
switch event.Severity {
|
||||
case SeverityWarning:
|
||||
color = "#daa038" // Orange
|
||||
case SeverityError, SeverityCritical:
|
||||
color = "#cc0000" // Red
|
||||
}
|
||||
|
||||
fields := []AttachmentField{}
|
||||
|
||||
if event.Database != "" {
|
||||
fields = append(fields, AttachmentField{
|
||||
Title: "Database",
|
||||
Value: event.Database,
|
||||
Short: true,
|
||||
})
|
||||
}
|
||||
|
||||
if event.Duration > 0 {
|
||||
fields = append(fields, AttachmentField{
|
||||
Title: "Duration",
|
||||
Value: event.Duration.Round(time.Second).String(),
|
||||
Short: true,
|
||||
})
|
||||
}
|
||||
|
||||
if event.BackupSize > 0 {
|
||||
fields = append(fields, AttachmentField{
|
||||
Title: "Size",
|
||||
Value: formatBytes(event.BackupSize),
|
||||
Short: true,
|
||||
})
|
||||
}
|
||||
|
||||
if event.Hostname != "" {
|
||||
fields = append(fields, AttachmentField{
|
||||
Title: "Host",
|
||||
Value: event.Hostname,
|
||||
Short: true,
|
||||
})
|
||||
}
|
||||
|
||||
if event.Error != "" {
|
||||
fields = append(fields, AttachmentField{
|
||||
Title: "Error",
|
||||
Value: event.Error,
|
||||
Short: false,
|
||||
})
|
||||
}
|
||||
|
||||
payload := SlackPayload{
|
||||
Username: "DBBackup",
|
||||
IconEmoji: ":database:",
|
||||
Attachments: []Attachment{
|
||||
{
|
||||
Color: color,
|
||||
Title: FormatEventSubject(event),
|
||||
Text: event.Message,
|
||||
Fields: fields,
|
||||
Footer: "dbbackup",
|
||||
Timestamp: event.Timestamp.Unix(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Marshal to JSON
|
||||
jsonBody, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("slack: failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
// Send with retries
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= s.config.Retries; attempt++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
if attempt > 0 {
|
||||
time.Sleep(s.config.RetryDelay)
|
||||
}
|
||||
|
||||
err := s.doRequest(ctx, jsonBody)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
return fmt.Errorf("slack: failed after %d attempts: %w", s.config.Retries+1, lastErr)
|
||||
}
|
||||
|
||||
// doRequest performs the HTTP request to Slack
|
||||
func (s *SlackWebhookNotifier) doRequest(ctx context.Context, body []byte) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", s.webhookURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user