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

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

View 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
View 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
View 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
}