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

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
}