Files
dbbackup/internal/notify/manager.go
Alexander Renz d0d83b61ef 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
2025-12-13 19:00:54 +01:00

257 lines
6.9 KiB
Go

// 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),
}
}