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:
2025-12-13 19:00:54 +01:00
parent 2becde8077
commit d0d83b61ef
15 changed files with 3080 additions and 5 deletions

260
internal/notify/notify.go Normal file
View 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])
}