feat: Add enterprise DBA features for production reliability

New features implemented:

1. Backup Catalog (internal/catalog/)
   - SQLite-based backup tracking
   - Gap detection and RPO monitoring
   - Search and statistics
   - Filesystem sync

2. DR Drill Testing (internal/drill/)
   - Automated restore testing in Docker containers
   - Database validation with custom queries
   - Catalog integration for drill-tested status

3. Smart Notifications (internal/notify/)
   - Event batching with configurable intervals
   - Time-based escalation policies
   - HTML/text/Slack templates

4. Compliance Reports (internal/report/)
   - SOC2, GDPR, HIPAA, PCI-DSS, ISO27001 frameworks
   - Evidence collection from catalog
   - JSON, Markdown, HTML output formats

5. RTO/RPO Calculator (internal/rto/)
   - Recovery objective analysis
   - RTO breakdown by phase
   - Recommendations for improvement

6. Replica-Aware Backup (internal/replica/)
   - Topology detection for PostgreSQL/MySQL
   - Automatic replica selection
   - Configurable selection strategies

7. Parallel Table Backup (internal/parallel/)
   - Concurrent table dumps
   - Worker pool with progress tracking
   - Large table optimization

8. MySQL/MariaDB PITR (internal/pitr/)
   - Binary log parsing and replay
   - Point-in-time recovery support
   - Transaction filtering

CLI commands added: catalog, drill, report, rto

All changes support the goal: reliable 3 AM database recovery.
This commit is contained in:
2025-12-13 20:28:55 +01:00
parent d0d83b61ef
commit f69bfe7071
34 changed files with 13469 additions and 41 deletions

261
internal/notify/batch.go Normal file
View File

@@ -0,0 +1,261 @@
// Package notify - Event batching for aggregated notifications
package notify
import (
"context"
"fmt"
"sync"
"time"
)
// BatchConfig configures notification batching
type BatchConfig struct {
Enabled bool // Enable batching
Window time.Duration // Batch window (e.g., 5 minutes)
MaxEvents int // Maximum events per batch before forced send
GroupBy string // Group by: "database", "type", "severity", "host"
DigestFormat string // Format: "summary", "detailed", "compact"
}
// DefaultBatchConfig returns sensible batch defaults
func DefaultBatchConfig() BatchConfig {
return BatchConfig{
Enabled: false,
Window: 5 * time.Minute,
MaxEvents: 50,
GroupBy: "database",
DigestFormat: "summary",
}
}
// Batcher collects events and sends them in batches
type Batcher struct {
config BatchConfig
manager *Manager
events []*Event
mu sync.Mutex
timer *time.Timer
ctx context.Context
cancel context.CancelFunc
startTime time.Time
}
// NewBatcher creates a new event batcher
func NewBatcher(config BatchConfig, manager *Manager) *Batcher {
ctx, cancel := context.WithCancel(context.Background())
return &Batcher{
config: config,
manager: manager,
events: make([]*Event, 0),
ctx: ctx,
cancel: cancel,
}
}
// Add adds an event to the batch
func (b *Batcher) Add(event *Event) {
if !b.config.Enabled {
// Batching disabled, send immediately
b.manager.Notify(event)
return
}
b.mu.Lock()
defer b.mu.Unlock()
// Start timer on first event
if len(b.events) == 0 {
b.startTime = time.Now()
b.timer = time.AfterFunc(b.config.Window, func() {
b.Flush()
})
}
b.events = append(b.events, event)
// Check if we've hit max events
if len(b.events) >= b.config.MaxEvents {
b.flushLocked()
}
}
// Flush sends all batched events
func (b *Batcher) Flush() {
b.mu.Lock()
defer b.mu.Unlock()
b.flushLocked()
}
// flushLocked sends batched events (must hold mutex)
func (b *Batcher) flushLocked() {
if len(b.events) == 0 {
return
}
// Cancel pending timer
if b.timer != nil {
b.timer.Stop()
b.timer = nil
}
// Group events
groups := b.groupEvents()
// Create digest event for each group
for key, events := range groups {
digest := b.createDigest(key, events)
b.manager.Notify(digest)
}
// Clear events
b.events = make([]*Event, 0)
}
// groupEvents groups events by configured criteria
func (b *Batcher) groupEvents() map[string][]*Event {
groups := make(map[string][]*Event)
for _, event := range b.events {
var key string
switch b.config.GroupBy {
case "database":
key = event.Database
case "type":
key = string(event.Type)
case "severity":
key = string(event.Severity)
case "host":
key = event.Hostname
default:
key = "all"
}
if key == "" {
key = "unknown"
}
groups[key] = append(groups[key], event)
}
return groups
}
// createDigest creates a digest event from multiple events
func (b *Batcher) createDigest(groupKey string, events []*Event) *Event {
// Calculate summary stats
var (
successCount int
failureCount int
highestSev = SeverityInfo
totalDuration time.Duration
databases = make(map[string]bool)
)
for _, e := range events {
switch e.Type {
case EventBackupCompleted, EventRestoreCompleted, EventVerifyCompleted:
successCount++
case EventBackupFailed, EventRestoreFailed, EventVerifyFailed:
failureCount++
}
if severityOrder(e.Severity) > severityOrder(highestSev) {
highestSev = e.Severity
}
totalDuration += e.Duration
if e.Database != "" {
databases[e.Database] = true
}
}
// Create digest message
var message string
switch b.config.DigestFormat {
case "detailed":
message = b.formatDetailedDigest(events)
case "compact":
message = b.formatCompactDigest(events, successCount, failureCount)
default: // summary
message = b.formatSummaryDigest(events, successCount, failureCount, len(databases))
}
digest := NewEvent(EventType("digest"), highestSev, message)
digest.WithDetail("group", groupKey)
digest.WithDetail("event_count", fmt.Sprintf("%d", len(events)))
digest.WithDetail("success_count", fmt.Sprintf("%d", successCount))
digest.WithDetail("failure_count", fmt.Sprintf("%d", failureCount))
digest.WithDetail("batch_duration", fmt.Sprintf("%.0fs", time.Since(b.startTime).Seconds()))
if len(databases) == 1 {
for db := range databases {
digest.Database = db
}
}
return digest
}
func (b *Batcher) formatSummaryDigest(events []*Event, success, failure, dbCount int) string {
total := len(events)
return fmt.Sprintf("Batch Summary: %d events (%d success, %d failed) across %d database(s)",
total, success, failure, dbCount)
}
func (b *Batcher) formatCompactDigest(events []*Event, success, failure int) string {
if failure > 0 {
return fmt.Sprintf("⚠️ %d/%d operations failed", failure, len(events))
}
return fmt.Sprintf("✅ All %d operations successful", success)
}
func (b *Batcher) formatDetailedDigest(events []*Event) string {
var msg string
msg += fmt.Sprintf("=== Batch Digest (%d events) ===\n\n", len(events))
for _, e := range events {
icon := "•"
switch e.Severity {
case SeverityError, SeverityCritical:
icon = "❌"
case SeverityWarning:
icon = "⚠️"
}
msg += fmt.Sprintf("%s [%s] %s: %s\n",
icon,
e.Timestamp.Format("15:04:05"),
e.Type,
e.Message)
}
return msg
}
// Stop stops the batcher and flushes remaining events
func (b *Batcher) Stop() {
b.cancel()
b.Flush()
}
// BatcherStats returns current batcher statistics
type BatcherStats struct {
PendingEvents int `json:"pending_events"`
BatchAge time.Duration `json:"batch_age"`
Config BatchConfig `json:"config"`
}
// Stats returns current batcher statistics
func (b *Batcher) Stats() BatcherStats {
b.mu.Lock()
defer b.mu.Unlock()
var age time.Duration
if len(b.events) > 0 {
age = time.Since(b.startTime)
}
return BatcherStats{
PendingEvents: len(b.events),
BatchAge: age,
Config: b.config,
}
}

363
internal/notify/escalate.go Normal file
View File

@@ -0,0 +1,363 @@
// Package notify - Escalation for critical events
package notify
import (
"context"
"fmt"
"sync"
"time"
)
// EscalationConfig configures notification escalation
type EscalationConfig struct {
Enabled bool // Enable escalation
Levels []EscalationLevel // Escalation levels
AcknowledgeURL string // URL to acknowledge alerts
CooldownPeriod time.Duration // Cooldown between escalations
RepeatInterval time.Duration // Repeat unacknowledged alerts
MaxRepeats int // Maximum repeat attempts
TrackingEnabled bool // Track escalation state
}
// EscalationLevel defines an escalation tier
type EscalationLevel struct {
Name string // Level name (e.g., "primary", "secondary", "manager")
Delay time.Duration // Delay before escalating to this level
Recipients []string // Email recipients for this level
Webhook string // Webhook URL for this level
Severity Severity // Minimum severity to escalate
Message string // Custom message template
}
// DefaultEscalationConfig returns sensible defaults
func DefaultEscalationConfig() EscalationConfig {
return EscalationConfig{
Enabled: false,
CooldownPeriod: 15 * time.Minute,
RepeatInterval: 30 * time.Minute,
MaxRepeats: 3,
Levels: []EscalationLevel{
{
Name: "primary",
Delay: 0,
Severity: SeverityError,
},
{
Name: "secondary",
Delay: 15 * time.Minute,
Severity: SeverityError,
},
{
Name: "critical",
Delay: 30 * time.Minute,
Severity: SeverityCritical,
},
},
}
}
// EscalationState tracks escalation for an alert
type EscalationState struct {
AlertID string `json:"alert_id"`
Event *Event `json:"event"`
CurrentLevel int `json:"current_level"`
StartedAt time.Time `json:"started_at"`
LastEscalation time.Time `json:"last_escalation"`
RepeatCount int `json:"repeat_count"`
Acknowledged bool `json:"acknowledged"`
AcknowledgedBy string `json:"acknowledged_by,omitempty"`
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
Resolved bool `json:"resolved"`
}
// Escalator manages alert escalation
type Escalator struct {
config EscalationConfig
manager *Manager
alerts map[string]*EscalationState
mu sync.RWMutex
ctx context.Context
cancel context.CancelFunc
ticker *time.Ticker
}
// NewEscalator creates a new escalation manager
func NewEscalator(config EscalationConfig, manager *Manager) *Escalator {
ctx, cancel := context.WithCancel(context.Background())
e := &Escalator{
config: config,
manager: manager,
alerts: make(map[string]*EscalationState),
ctx: ctx,
cancel: cancel,
}
if config.Enabled {
e.ticker = time.NewTicker(time.Minute)
go e.runEscalationLoop()
}
return e
}
// Handle processes an event for potential escalation
func (e *Escalator) Handle(event *Event) {
if !e.config.Enabled {
return
}
// Only escalate errors and critical events
if severityOrder(event.Severity) < severityOrder(SeverityError) {
return
}
// Generate alert ID
alertID := e.generateAlertID(event)
e.mu.Lock()
defer e.mu.Unlock()
// Check if alert already exists
if existing, ok := e.alerts[alertID]; ok {
if !existing.Acknowledged && !existing.Resolved {
// Alert already being escalated
return
}
}
// Create new escalation state
state := &EscalationState{
AlertID: alertID,
Event: event,
CurrentLevel: 0,
StartedAt: time.Now(),
LastEscalation: time.Now(),
}
e.alerts[alertID] = state
// Send immediate notification to first level
e.notifyLevel(state, 0)
}
// generateAlertID creates a unique ID for an alert
func (e *Escalator) generateAlertID(event *Event) string {
return fmt.Sprintf("%s_%s_%s",
event.Type,
event.Database,
event.Hostname)
}
// notifyLevel sends notification for a specific escalation level
func (e *Escalator) notifyLevel(state *EscalationState, level int) {
if level >= len(e.config.Levels) {
return
}
lvl := e.config.Levels[level]
// Create escalated event
escalatedEvent := &Event{
Type: state.Event.Type,
Severity: state.Event.Severity,
Timestamp: time.Now(),
Database: state.Event.Database,
Hostname: state.Event.Hostname,
Message: e.formatEscalationMessage(state, lvl),
Details: make(map[string]string),
}
escalatedEvent.Details["escalation_level"] = lvl.Name
escalatedEvent.Details["alert_id"] = state.AlertID
escalatedEvent.Details["escalation_time"] = fmt.Sprintf("%d", int(time.Since(state.StartedAt).Minutes()))
escalatedEvent.Details["original_message"] = state.Event.Message
if state.Event.Error != "" {
escalatedEvent.Error = state.Event.Error
}
// Send via manager
e.manager.Notify(escalatedEvent)
state.CurrentLevel = level
state.LastEscalation = time.Now()
}
// formatEscalationMessage creates an escalation message
func (e *Escalator) formatEscalationMessage(state *EscalationState, level EscalationLevel) string {
if level.Message != "" {
return level.Message
}
elapsed := time.Since(state.StartedAt)
return fmt.Sprintf("🚨 ESCALATION [%s] - Alert unacknowledged for %s\n\n%s",
level.Name,
formatDuration(elapsed),
state.Event.Message)
}
// runEscalationLoop checks for alerts that need escalation
func (e *Escalator) runEscalationLoop() {
for {
select {
case <-e.ctx.Done():
return
case <-e.ticker.C:
e.checkEscalations()
}
}
}
// checkEscalations checks all alerts for needed escalation
func (e *Escalator) checkEscalations() {
e.mu.Lock()
defer e.mu.Unlock()
now := time.Now()
for _, state := range e.alerts {
if state.Acknowledged || state.Resolved {
continue
}
// Check if we need to escalate to next level
nextLevel := state.CurrentLevel + 1
if nextLevel < len(e.config.Levels) {
lvl := e.config.Levels[nextLevel]
if now.Sub(state.StartedAt) >= lvl.Delay {
e.notifyLevel(state, nextLevel)
}
}
// Check if we need to repeat the alert
if state.RepeatCount < e.config.MaxRepeats {
if now.Sub(state.LastEscalation) >= e.config.RepeatInterval {
e.notifyLevel(state, state.CurrentLevel)
state.RepeatCount++
}
}
}
}
// Acknowledge acknowledges an alert
func (e *Escalator) Acknowledge(alertID, user string) error {
e.mu.Lock()
defer e.mu.Unlock()
state, ok := e.alerts[alertID]
if !ok {
return fmt.Errorf("alert not found: %s", alertID)
}
now := time.Now()
state.Acknowledged = true
state.AcknowledgedBy = user
state.AcknowledgedAt = &now
return nil
}
// Resolve resolves an alert
func (e *Escalator) Resolve(alertID string) error {
e.mu.Lock()
defer e.mu.Unlock()
state, ok := e.alerts[alertID]
if !ok {
return fmt.Errorf("alert not found: %s", alertID)
}
state.Resolved = true
return nil
}
// GetActiveAlerts returns all active (unacknowledged, unresolved) alerts
func (e *Escalator) GetActiveAlerts() []*EscalationState {
e.mu.RLock()
defer e.mu.RUnlock()
var active []*EscalationState
for _, state := range e.alerts {
if !state.Acknowledged && !state.Resolved {
active = append(active, state)
}
}
return active
}
// GetAlert returns a specific alert
func (e *Escalator) GetAlert(alertID string) (*EscalationState, bool) {
e.mu.RLock()
defer e.mu.RUnlock()
state, ok := e.alerts[alertID]
return state, ok
}
// CleanupOld removes old resolved/acknowledged alerts
func (e *Escalator) CleanupOld(maxAge time.Duration) int {
e.mu.Lock()
defer e.mu.Unlock()
now := time.Now()
removed := 0
for id, state := range e.alerts {
if (state.Acknowledged || state.Resolved) && now.Sub(state.StartedAt) > maxAge {
delete(e.alerts, id)
removed++
}
}
return removed
}
// Stop stops the escalator
func (e *Escalator) Stop() {
e.cancel()
if e.ticker != nil {
e.ticker.Stop()
}
}
// EscalatorStats returns escalator statistics
type EscalatorStats struct {
ActiveAlerts int `json:"active_alerts"`
AcknowledgedAlerts int `json:"acknowledged_alerts"`
ResolvedAlerts int `json:"resolved_alerts"`
EscalationEnabled bool `json:"escalation_enabled"`
LevelCount int `json:"level_count"`
}
// Stats returns escalator statistics
func (e *Escalator) Stats() EscalatorStats {
e.mu.RLock()
defer e.mu.RUnlock()
stats := EscalatorStats{
EscalationEnabled: e.config.Enabled,
LevelCount: len(e.config.Levels),
}
for _, state := range e.alerts {
if state.Resolved {
stats.ResolvedAlerts++
} else if state.Acknowledged {
stats.AcknowledgedAlerts++
} else {
stats.ActiveAlerts++
}
}
return stats
}
func formatDuration(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%.0fs", d.Seconds())
}
if d < time.Hour {
return fmt.Sprintf("%.0fm", d.Minutes())
}
return fmt.Sprintf("%.0fh %.0fm", d.Hours(), d.Minutes()-d.Hours()*60)
}

View File

@@ -11,41 +11,66 @@ import (
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"
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"
EventVerificationPassed EventType = "verification_passed"
EventVerificationFailed EventType = "verification_failed"
EventDRDrillPassed EventType = "dr_drill_passed"
EventDRDrillFailed EventType = "dr_drill_failed"
EventGapDetected EventType = "gap_detected"
EventRPOViolation EventType = "rpo_violation"
)
// Severity represents the severity level of a notification
type Severity string
const (
SeverityInfo Severity = "info"
SeverityWarning Severity = "warning"
SeverityError Severity = "error"
SeverityInfo Severity = "info"
SeveritySuccess Severity = "success"
SeverityWarning Severity = "warning"
SeverityError Severity = "error"
SeverityCritical Severity = "critical"
)
// severityOrder returns numeric order for severity comparison
func severityOrder(s Severity) int {
switch s {
case SeverityInfo:
return 0
case SeveritySuccess:
return 1
case SeverityWarning:
return 2
case SeverityError:
return 3
case SeverityCritical:
return 4
default:
return 0
}
}
// 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"`
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
@@ -132,27 +157,27 @@ type Config struct {
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
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,
SMTPPort: 587,
SMTPTLS: false,
SMTPStartTLS: true,
WebhookMethod: "POST",
OnSuccess: true,
OnFailure: true,
OnWarning: true,
MinSeverity: SeverityInfo,
Retries: 3,
RetryDelay: 5 * time.Second,
OnSuccess: true,
OnFailure: true,
OnWarning: true,
MinSeverity: SeverityInfo,
Retries: 3,
RetryDelay: 5 * time.Second,
}
}

View File

@@ -0,0 +1,497 @@
// Package notify - Notification templates
package notify
import (
"bytes"
"fmt"
"html/template"
"strings"
"time"
)
// TemplateType represents the notification format type
type TemplateType string
const (
TemplateText TemplateType = "text"
TemplateHTML TemplateType = "html"
TemplateMarkdown TemplateType = "markdown"
TemplateSlack TemplateType = "slack"
)
// Templates holds notification templates
type Templates struct {
Subject string
TextBody string
HTMLBody string
}
// DefaultTemplates returns default notification templates
func DefaultTemplates() map[EventType]Templates {
return map[EventType]Templates{
EventBackupStarted: {
Subject: "🔄 Backup Started: {{.Database}} on {{.Hostname}}",
TextBody: backupStartedText,
HTMLBody: backupStartedHTML,
},
EventBackupCompleted: {
Subject: "✅ Backup Completed: {{.Database}} on {{.Hostname}}",
TextBody: backupCompletedText,
HTMLBody: backupCompletedHTML,
},
EventBackupFailed: {
Subject: "❌ Backup FAILED: {{.Database}} on {{.Hostname}}",
TextBody: backupFailedText,
HTMLBody: backupFailedHTML,
},
EventRestoreStarted: {
Subject: "🔄 Restore Started: {{.Database}} on {{.Hostname}}",
TextBody: restoreStartedText,
HTMLBody: restoreStartedHTML,
},
EventRestoreCompleted: {
Subject: "✅ Restore Completed: {{.Database}} on {{.Hostname}}",
TextBody: restoreCompletedText,
HTMLBody: restoreCompletedHTML,
},
EventRestoreFailed: {
Subject: "❌ Restore FAILED: {{.Database}} on {{.Hostname}}",
TextBody: restoreFailedText,
HTMLBody: restoreFailedHTML,
},
EventVerificationPassed: {
Subject: "✅ Verification Passed: {{.Database}}",
TextBody: verificationPassedText,
HTMLBody: verificationPassedHTML,
},
EventVerificationFailed: {
Subject: "❌ Verification FAILED: {{.Database}}",
TextBody: verificationFailedText,
HTMLBody: verificationFailedHTML,
},
EventDRDrillPassed: {
Subject: "✅ DR Drill Passed: {{.Database}}",
TextBody: drDrillPassedText,
HTMLBody: drDrillPassedHTML,
},
EventDRDrillFailed: {
Subject: "❌ DR Drill FAILED: {{.Database}}",
TextBody: drDrillFailedText,
HTMLBody: drDrillFailedHTML,
},
}
}
// Template strings
const backupStartedText = `
Backup Operation Started
Database: {{.Database}}
Hostname: {{.Hostname}}
Started At: {{formatTime .Timestamp}}
{{if .Message}}{{.Message}}{{end}}
`
const backupStartedHTML = `
<div style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #3498db;">🔄 Backup Started</h2>
<table style="border-collapse: collapse; width: 100%; max-width: 600px;">
<tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Started At:</td><td style="padding: 8px;">{{formatTime .Timestamp}}</td></tr>
</table>
{{if .Message}}<p style="margin-top: 20px;">{{.Message}}</p>{{end}}
</div>
`
const backupCompletedText = `
Backup Operation Completed Successfully
Database: {{.Database}}
Hostname: {{.Hostname}}
Completed: {{formatTime .Timestamp}}
{{with .Details}}
{{if .size}}Size: {{.size}}{{end}}
{{if .duration}}Duration: {{.duration}}{{end}}
{{if .path}}Path: {{.path}}{{end}}
{{end}}
{{if .Message}}{{.Message}}{{end}}
`
const backupCompletedHTML = `
<div style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #27ae60;">✅ Backup Completed</h2>
<table style="border-collapse: collapse; width: 100%; max-width: 600px;">
<tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Completed:</td><td style="padding: 8px;">{{formatTime .Timestamp}}</td></tr>
{{with .Details}}
{{if .size}}<tr><td style="padding: 8px; font-weight: bold;">Size:</td><td style="padding: 8px;">{{.size}}</td></tr>{{end}}
{{if .duration}}<tr><td style="padding: 8px; font-weight: bold;">Duration:</td><td style="padding: 8px;">{{.duration}}</td></tr>{{end}}
{{if .path}}<tr><td style="padding: 8px; font-weight: bold;">Path:</td><td style="padding: 8px;">{{.path}}</td></tr>{{end}}
{{end}}
</table>
{{if .Message}}<p style="margin-top: 20px; color: #27ae60;">{{.Message}}</p>{{end}}
</div>
`
const backupFailedText = `
⚠️ BACKUP FAILED ⚠️
Database: {{.Database}}
Hostname: {{.Hostname}}
Failed At: {{formatTime .Timestamp}}
{{if .Error}}
Error: {{.Error}}
{{end}}
{{if .Message}}{{.Message}}{{end}}
Please investigate immediately.
`
const backupFailedHTML = `
<div style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #e74c3c;">❌ Backup FAILED</h2>
<table style="border-collapse: collapse; width: 100%; max-width: 600px;">
<tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Failed At:</td><td style="padding: 8px;">{{formatTime .Timestamp}}</td></tr>
{{if .Error}}<tr><td style="padding: 8px; font-weight: bold; color: #e74c3c;">Error:</td><td style="padding: 8px; color: #e74c3c;">{{.Error}}</td></tr>{{end}}
</table>
{{if .Message}}<p style="margin-top: 20px;">{{.Message}}</p>{{end}}
<p style="margin-top: 20px; color: #e74c3c; font-weight: bold;">Please investigate immediately.</p>
</div>
`
const restoreStartedText = `
Restore Operation Started
Database: {{.Database}}
Hostname: {{.Hostname}}
Started At: {{formatTime .Timestamp}}
{{if .Message}}{{.Message}}{{end}}
`
const restoreStartedHTML = `
<div style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #3498db;">🔄 Restore Started</h2>
<table style="border-collapse: collapse; width: 100%; max-width: 600px;">
<tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Started At:</td><td style="padding: 8px;">{{formatTime .Timestamp}}</td></tr>
</table>
{{if .Message}}<p style="margin-top: 20px;">{{.Message}}</p>{{end}}
</div>
`
const restoreCompletedText = `
Restore Operation Completed Successfully
Database: {{.Database}}
Hostname: {{.Hostname}}
Completed: {{formatTime .Timestamp}}
{{with .Details}}
{{if .duration}}Duration: {{.duration}}{{end}}
{{end}}
{{if .Message}}{{.Message}}{{end}}
`
const restoreCompletedHTML = `
<div style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #27ae60;">✅ Restore Completed</h2>
<table style="border-collapse: collapse; width: 100%; max-width: 600px;">
<tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Completed:</td><td style="padding: 8px;">{{formatTime .Timestamp}}</td></tr>
{{with .Details}}
{{if .duration}}<tr><td style="padding: 8px; font-weight: bold;">Duration:</td><td style="padding: 8px;">{{.duration}}</td></tr>{{end}}
{{end}}
</table>
{{if .Message}}<p style="margin-top: 20px; color: #27ae60;">{{.Message}}</p>{{end}}
</div>
`
const restoreFailedText = `
⚠️ RESTORE FAILED ⚠️
Database: {{.Database}}
Hostname: {{.Hostname}}
Failed At: {{formatTime .Timestamp}}
{{if .Error}}
Error: {{.Error}}
{{end}}
{{if .Message}}{{.Message}}{{end}}
Please investigate immediately.
`
const restoreFailedHTML = `
<div style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #e74c3c;">❌ Restore FAILED</h2>
<table style="border-collapse: collapse; width: 100%; max-width: 600px;">
<tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Failed At:</td><td style="padding: 8px;">{{formatTime .Timestamp}}</td></tr>
{{if .Error}}<tr><td style="padding: 8px; font-weight: bold; color: #e74c3c;">Error:</td><td style="padding: 8px; color: #e74c3c;">{{.Error}}</td></tr>{{end}}
</table>
{{if .Message}}<p style="margin-top: 20px;">{{.Message}}</p>{{end}}
<p style="margin-top: 20px; color: #e74c3c; font-weight: bold;">Please investigate immediately.</p>
</div>
`
const verificationPassedText = `
Backup Verification Passed
Database: {{.Database}}
Hostname: {{.Hostname}}
Verified: {{formatTime .Timestamp}}
{{with .Details}}
{{if .checksum}}Checksum: {{.checksum}}{{end}}
{{end}}
{{if .Message}}{{.Message}}{{end}}
`
const verificationPassedHTML = `
<div style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #27ae60;">✅ Verification Passed</h2>
<table style="border-collapse: collapse; width: 100%; max-width: 600px;">
<tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Verified:</td><td style="padding: 8px;">{{formatTime .Timestamp}}</td></tr>
{{with .Details}}
{{if .checksum}}<tr><td style="padding: 8px; font-weight: bold;">Checksum:</td><td style="padding: 8px; font-family: monospace;">{{.checksum}}</td></tr>{{end}}
{{end}}
</table>
{{if .Message}}<p style="margin-top: 20px; color: #27ae60;">{{.Message}}</p>{{end}}
</div>
`
const verificationFailedText = `
⚠️ VERIFICATION FAILED ⚠️
Database: {{.Database}}
Hostname: {{.Hostname}}
Failed At: {{formatTime .Timestamp}}
{{if .Error}}
Error: {{.Error}}
{{end}}
{{if .Message}}{{.Message}}{{end}}
Backup integrity may be compromised. Please investigate.
`
const verificationFailedHTML = `
<div style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #e74c3c;">❌ Verification FAILED</h2>
<table style="border-collapse: collapse; width: 100%; max-width: 600px;">
<tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Failed At:</td><td style="padding: 8px;">{{formatTime .Timestamp}}</td></tr>
{{if .Error}}<tr><td style="padding: 8px; font-weight: bold; color: #e74c3c;">Error:</td><td style="padding: 8px; color: #e74c3c;">{{.Error}}</td></tr>{{end}}
</table>
{{if .Message}}<p style="margin-top: 20px;">{{.Message}}</p>{{end}}
<p style="margin-top: 20px; color: #e74c3c; font-weight: bold;">Backup integrity may be compromised. Please investigate.</p>
</div>
`
const drDrillPassedText = `
DR Drill Test Passed
Database: {{.Database}}
Hostname: {{.Hostname}}
Tested At: {{formatTime .Timestamp}}
{{with .Details}}
{{if .tables_restored}}Tables: {{.tables_restored}}{{end}}
{{if .rows_validated}}Rows: {{.rows_validated}}{{end}}
{{if .duration}}Duration: {{.duration}}{{end}}
{{end}}
{{if .Message}}{{.Message}}{{end}}
Backup restore capability verified.
`
const drDrillPassedHTML = `
<div style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #27ae60;">✅ DR Drill Passed</h2>
<table style="border-collapse: collapse; width: 100%; max-width: 600px;">
<tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Tested At:</td><td style="padding: 8px;">{{formatTime .Timestamp}}</td></tr>
{{with .Details}}
{{if .tables_restored}}<tr><td style="padding: 8px; font-weight: bold;">Tables:</td><td style="padding: 8px;">{{.tables_restored}}</td></tr>{{end}}
{{if .rows_validated}}<tr><td style="padding: 8px; font-weight: bold;">Rows:</td><td style="padding: 8px;">{{.rows_validated}}</td></tr>{{end}}
{{if .duration}}<tr><td style="padding: 8px; font-weight: bold;">Duration:</td><td style="padding: 8px;">{{.duration}}</td></tr>{{end}}
{{end}}
</table>
{{if .Message}}<p style="margin-top: 20px; color: #27ae60;">{{.Message}}</p>{{end}}
<p style="margin-top: 20px; color: #27ae60;">✓ Backup restore capability verified</p>
</div>
`
const drDrillFailedText = `
⚠️ DR DRILL FAILED ⚠️
Database: {{.Database}}
Hostname: {{.Hostname}}
Failed At: {{formatTime .Timestamp}}
{{if .Error}}
Error: {{.Error}}
{{end}}
{{if .Message}}{{.Message}}{{end}}
Backup may not be restorable. Please investigate immediately.
`
const drDrillFailedHTML = `
<div style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #e74c3c;">❌ DR Drill FAILED</h2>
<table style="border-collapse: collapse; width: 100%; max-width: 600px;">
<tr><td style="padding: 8px; font-weight: bold;">Database:</td><td style="padding: 8px;">{{.Database}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Hostname:</td><td style="padding: 8px;">{{.Hostname}}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Failed At:</td><td style="padding: 8px;">{{formatTime .Timestamp}}</td></tr>
{{if .Error}}<tr><td style="padding: 8px; font-weight: bold; color: #e74c3c;">Error:</td><td style="padding: 8px; color: #e74c3c;">{{.Error}}</td></tr>{{end}}
</table>
{{if .Message}}<p style="margin-top: 20px;">{{.Message}}</p>{{end}}
<p style="margin-top: 20px; color: #e74c3c; font-weight: bold;">Backup may not be restorable. Please investigate immediately.</p>
</div>
`
// TemplateRenderer renders notification templates
type TemplateRenderer struct {
templates map[EventType]Templates
funcMap template.FuncMap
}
// NewTemplateRenderer creates a new template renderer
func NewTemplateRenderer() *TemplateRenderer {
return &TemplateRenderer{
templates: DefaultTemplates(),
funcMap: template.FuncMap{
"formatTime": func(t time.Time) string {
return t.Format("2006-01-02 15:04:05 MST")
},
"upper": strings.ToUpper,
"lower": strings.ToLower,
},
}
}
// RenderSubject renders the subject template for an event
func (r *TemplateRenderer) RenderSubject(event *Event) (string, error) {
tmpl, ok := r.templates[event.Type]
if !ok {
return fmt.Sprintf("[%s] %s: %s", event.Severity, event.Type, event.Database), nil
}
return r.render(tmpl.Subject, event)
}
// RenderText renders the text body template for an event
func (r *TemplateRenderer) RenderText(event *Event) (string, error) {
tmpl, ok := r.templates[event.Type]
if !ok {
return event.Message, nil
}
return r.render(tmpl.TextBody, event)
}
// RenderHTML renders the HTML body template for an event
func (r *TemplateRenderer) RenderHTML(event *Event) (string, error) {
tmpl, ok := r.templates[event.Type]
if !ok {
return fmt.Sprintf("<p>%s</p>", event.Message), nil
}
return r.render(tmpl.HTMLBody, event)
}
// render executes a template with the given event
func (r *TemplateRenderer) render(templateStr string, event *Event) (string, error) {
tmpl, err := template.New("notification").Funcs(r.funcMap).Parse(templateStr)
if err != nil {
return "", fmt.Errorf("failed to parse template: %w", err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, event); err != nil {
return "", fmt.Errorf("failed to execute template: %w", err)
}
return strings.TrimSpace(buf.String()), nil
}
// SetTemplate sets a custom template for an event type
func (r *TemplateRenderer) SetTemplate(eventType EventType, templates Templates) {
r.templates[eventType] = templates
}
// RenderSlackMessage creates a Slack-formatted message
func (r *TemplateRenderer) RenderSlackMessage(event *Event) map[string]interface{} {
color := "#3498db" // blue
switch event.Severity {
case SeveritySuccess:
color = "#27ae60" // green
case SeverityWarning:
color = "#f39c12" // orange
case SeverityError, SeverityCritical:
color = "#e74c3c" // red
}
fields := []map[string]interface{}{
{
"title": "Database",
"value": event.Database,
"short": true,
},
{
"title": "Hostname",
"value": event.Hostname,
"short": true,
},
{
"title": "Event",
"value": string(event.Type),
"short": true,
},
{
"title": "Severity",
"value": string(event.Severity),
"short": true,
},
}
if event.Error != "" {
fields = append(fields, map[string]interface{}{
"title": "Error",
"value": event.Error,
"short": false,
})
}
for key, value := range event.Details {
fields = append(fields, map[string]interface{}{
"title": key,
"value": value,
"short": true,
})
}
subject, _ := r.RenderSubject(event)
return map[string]interface{}{
"attachments": []map[string]interface{}{
{
"color": color,
"title": subject,
"text": event.Message,
"fields": fields,
"footer": "dbbackup",
"ts": event.Timestamp.Unix(),
"mrkdwn_in": []string{"text", "fields"},
},
},
}
}