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:
188
internal/catalog/catalog.go
Normal file
188
internal/catalog/catalog.go
Normal file
@@ -0,0 +1,188 @@
|
||||
// Package catalog provides backup catalog management with SQLite storage
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Entry represents a single backup in the catalog
|
||||
type Entry struct {
|
||||
ID int64 `json:"id"`
|
||||
Database string `json:"database"`
|
||||
DatabaseType string `json:"database_type"` // postgresql, mysql, mariadb
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
BackupPath string `json:"backup_path"`
|
||||
BackupType string `json:"backup_type"` // full, incremental
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Compression string `json:"compression"`
|
||||
Encrypted bool `json:"encrypted"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Duration float64 `json:"duration_seconds"`
|
||||
Status BackupStatus `json:"status"`
|
||||
VerifiedAt *time.Time `json:"verified_at,omitempty"`
|
||||
VerifyValid *bool `json:"verify_valid,omitempty"`
|
||||
DrillTestedAt *time.Time `json:"drill_tested_at,omitempty"`
|
||||
DrillSuccess *bool `json:"drill_success,omitempty"`
|
||||
CloudLocation string `json:"cloud_location,omitempty"`
|
||||
RetentionPolicy string `json:"retention_policy,omitempty"` // daily, weekly, monthly, yearly
|
||||
Tags map[string]string `json:"tags,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// BackupStatus represents the state of a backup
|
||||
type BackupStatus string
|
||||
|
||||
const (
|
||||
StatusCompleted BackupStatus = "completed"
|
||||
StatusFailed BackupStatus = "failed"
|
||||
StatusVerified BackupStatus = "verified"
|
||||
StatusCorrupted BackupStatus = "corrupted"
|
||||
StatusDeleted BackupStatus = "deleted"
|
||||
StatusArchived BackupStatus = "archived"
|
||||
)
|
||||
|
||||
// Gap represents a detected backup gap
|
||||
type Gap struct {
|
||||
Database string `json:"database"`
|
||||
GapStart time.Time `json:"gap_start"`
|
||||
GapEnd time.Time `json:"gap_end"`
|
||||
Duration time.Duration `json:"duration"`
|
||||
ExpectedAt time.Time `json:"expected_at"`
|
||||
Description string `json:"description"`
|
||||
Severity GapSeverity `json:"severity"`
|
||||
}
|
||||
|
||||
// GapSeverity indicates how serious a backup gap is
|
||||
type GapSeverity string
|
||||
|
||||
const (
|
||||
SeverityInfo GapSeverity = "info" // Gap within tolerance
|
||||
SeverityWarning GapSeverity = "warning" // Gap exceeds expected interval
|
||||
SeverityCritical GapSeverity = "critical" // Gap exceeds RPO
|
||||
)
|
||||
|
||||
// Stats contains backup statistics
|
||||
type Stats struct {
|
||||
TotalBackups int64 `json:"total_backups"`
|
||||
TotalSize int64 `json:"total_size_bytes"`
|
||||
TotalSizeHuman string `json:"total_size_human"`
|
||||
OldestBackup *time.Time `json:"oldest_backup,omitempty"`
|
||||
NewestBackup *time.Time `json:"newest_backup,omitempty"`
|
||||
ByDatabase map[string]int64 `json:"by_database"`
|
||||
ByType map[string]int64 `json:"by_type"`
|
||||
ByStatus map[string]int64 `json:"by_status"`
|
||||
VerifiedCount int64 `json:"verified_count"`
|
||||
DrillTestedCount int64 `json:"drill_tested_count"`
|
||||
AvgDuration float64 `json:"avg_duration_seconds"`
|
||||
AvgSize int64 `json:"avg_size_bytes"`
|
||||
GapsDetected int `json:"gaps_detected"`
|
||||
}
|
||||
|
||||
// SearchQuery represents search criteria for catalog entries
|
||||
type SearchQuery struct {
|
||||
Database string // Filter by database name (supports wildcards)
|
||||
DatabaseType string // Filter by database type
|
||||
Host string // Filter by host
|
||||
Status string // Filter by status
|
||||
StartDate *time.Time // Backups after this date
|
||||
EndDate *time.Time // Backups before this date
|
||||
MinSize int64 // Minimum size in bytes
|
||||
MaxSize int64 // Maximum size in bytes
|
||||
BackupType string // full, incremental
|
||||
Encrypted *bool // Filter by encryption status
|
||||
Verified *bool // Filter by verification status
|
||||
DrillTested *bool // Filter by drill test status
|
||||
Limit int // Max results (0 = no limit)
|
||||
Offset int // Offset for pagination
|
||||
OrderBy string // Field to order by
|
||||
OrderDesc bool // Order descending
|
||||
}
|
||||
|
||||
// GapDetectionConfig configures gap detection
|
||||
type GapDetectionConfig struct {
|
||||
ExpectedInterval time.Duration // Expected backup interval (e.g., 24h)
|
||||
Tolerance time.Duration // Allowed variance (e.g., 1h)
|
||||
RPOThreshold time.Duration // Critical threshold (RPO)
|
||||
StartDate *time.Time // Start of analysis window
|
||||
EndDate *time.Time // End of analysis window
|
||||
}
|
||||
|
||||
// Catalog defines the interface for backup catalog operations
|
||||
type Catalog interface {
|
||||
// Entry management
|
||||
Add(ctx context.Context, entry *Entry) error
|
||||
Update(ctx context.Context, entry *Entry) error
|
||||
Delete(ctx context.Context, id int64) error
|
||||
Get(ctx context.Context, id int64) (*Entry, error)
|
||||
GetByPath(ctx context.Context, path string) (*Entry, error)
|
||||
|
||||
// Search and listing
|
||||
Search(ctx context.Context, query *SearchQuery) ([]*Entry, error)
|
||||
List(ctx context.Context, database string, limit int) ([]*Entry, error)
|
||||
ListDatabases(ctx context.Context) ([]string, error)
|
||||
Count(ctx context.Context, query *SearchQuery) (int64, error)
|
||||
|
||||
// Statistics
|
||||
Stats(ctx context.Context) (*Stats, error)
|
||||
StatsByDatabase(ctx context.Context, database string) (*Stats, error)
|
||||
|
||||
// Gap detection
|
||||
DetectGaps(ctx context.Context, database string, config *GapDetectionConfig) ([]*Gap, error)
|
||||
DetectAllGaps(ctx context.Context, config *GapDetectionConfig) (map[string][]*Gap, error)
|
||||
|
||||
// Verification tracking
|
||||
MarkVerified(ctx context.Context, id int64, valid bool) error
|
||||
MarkDrillTested(ctx context.Context, id int64, success bool) error
|
||||
|
||||
// Sync with filesystem
|
||||
SyncFromDirectory(ctx context.Context, dir string) (*SyncResult, error)
|
||||
SyncFromCloud(ctx context.Context, provider, bucket, prefix string) (*SyncResult, error)
|
||||
|
||||
// Maintenance
|
||||
Prune(ctx context.Context, before time.Time) (int, error)
|
||||
Vacuum(ctx context.Context) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
// SyncResult contains results from a catalog sync operation
|
||||
type SyncResult struct {
|
||||
Added int `json:"added"`
|
||||
Updated int `json:"updated"`
|
||||
Removed int `json:"removed"`
|
||||
Errors int `json:"errors"`
|
||||
Duration float64 `json:"duration_seconds"`
|
||||
Details []string `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// FormatSize formats bytes as human-readable string
|
||||
func FormatSize(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])
|
||||
}
|
||||
|
||||
// FormatDuration formats duration as human-readable string
|
||||
func FormatDuration(d time.Duration) string {
|
||||
if d < time.Minute {
|
||||
return fmt.Sprintf("%.0fs", d.Seconds())
|
||||
}
|
||||
if d < time.Hour {
|
||||
mins := int(d.Minutes())
|
||||
secs := int(d.Seconds()) - mins*60
|
||||
return fmt.Sprintf("%dm %ds", mins, secs)
|
||||
}
|
||||
hours := int(d.Hours())
|
||||
mins := int(d.Minutes()) - hours*60
|
||||
return fmt.Sprintf("%dh %dm", hours, mins)
|
||||
}
|
||||
308
internal/catalog/catalog_test.go
Normal file
308
internal/catalog/catalog_test.go
Normal file
@@ -0,0 +1,308 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSQLiteCatalog(t *testing.T) {
|
||||
// Create temp directory for test database
|
||||
tmpDir, err := os.MkdirTemp("", "catalog_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test_catalog.db")
|
||||
|
||||
// Test creation
|
||||
cat, err := NewSQLiteCatalog(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create catalog: %v", err)
|
||||
}
|
||||
defer cat.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Test Add
|
||||
entry := &Entry{
|
||||
Database: "testdb",
|
||||
DatabaseType: "postgresql",
|
||||
Host: "localhost",
|
||||
Port: 5432,
|
||||
BackupPath: "/backups/testdb_20240115.dump.gz",
|
||||
BackupType: "full",
|
||||
SizeBytes: 1024 * 1024 * 100, // 100 MB
|
||||
SHA256: "abc123def456",
|
||||
Compression: "gzip",
|
||||
Encrypted: false,
|
||||
CreatedAt: time.Now().Add(-24 * time.Hour),
|
||||
Duration: 45.5,
|
||||
Status: StatusCompleted,
|
||||
}
|
||||
|
||||
err = cat.Add(ctx, entry)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to add entry: %v", err)
|
||||
}
|
||||
|
||||
if entry.ID == 0 {
|
||||
t.Error("Expected entry ID to be set after Add")
|
||||
}
|
||||
|
||||
// Test Get
|
||||
retrieved, err := cat.Get(ctx, entry.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get entry: %v", err)
|
||||
}
|
||||
|
||||
if retrieved == nil {
|
||||
t.Fatal("Expected to retrieve entry, got nil")
|
||||
}
|
||||
|
||||
if retrieved.Database != "testdb" {
|
||||
t.Errorf("Expected database 'testdb', got '%s'", retrieved.Database)
|
||||
}
|
||||
|
||||
if retrieved.SizeBytes != entry.SizeBytes {
|
||||
t.Errorf("Expected size %d, got %d", entry.SizeBytes, retrieved.SizeBytes)
|
||||
}
|
||||
|
||||
// Test GetByPath
|
||||
byPath, err := cat.GetByPath(ctx, entry.BackupPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get by path: %v", err)
|
||||
}
|
||||
|
||||
if byPath == nil || byPath.ID != entry.ID {
|
||||
t.Error("GetByPath returned wrong entry")
|
||||
}
|
||||
|
||||
// Test List
|
||||
entries, err := cat.List(ctx, "testdb", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list entries: %v", err)
|
||||
}
|
||||
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("Expected 1 entry, got %d", len(entries))
|
||||
}
|
||||
|
||||
// Test ListDatabases
|
||||
databases, err := cat.ListDatabases(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list databases: %v", err)
|
||||
}
|
||||
|
||||
if len(databases) != 1 || databases[0] != "testdb" {
|
||||
t.Errorf("Expected ['testdb'], got %v", databases)
|
||||
}
|
||||
|
||||
// Test Stats
|
||||
stats, err := cat.Stats(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get stats: %v", err)
|
||||
}
|
||||
|
||||
if stats.TotalBackups != 1 {
|
||||
t.Errorf("Expected 1 total backup, got %d", stats.TotalBackups)
|
||||
}
|
||||
|
||||
if stats.TotalSize != entry.SizeBytes {
|
||||
t.Errorf("Expected size %d, got %d", entry.SizeBytes, stats.TotalSize)
|
||||
}
|
||||
|
||||
// Test MarkVerified
|
||||
err = cat.MarkVerified(ctx, entry.ID, true)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to mark verified: %v", err)
|
||||
}
|
||||
|
||||
verified, _ := cat.Get(ctx, entry.ID)
|
||||
if verified.VerifiedAt == nil {
|
||||
t.Error("Expected VerifiedAt to be set")
|
||||
}
|
||||
if verified.VerifyValid == nil || !*verified.VerifyValid {
|
||||
t.Error("Expected VerifyValid to be true")
|
||||
}
|
||||
|
||||
// Test Update
|
||||
entry.SizeBytes = 200 * 1024 * 1024 // 200 MB
|
||||
err = cat.Update(ctx, entry)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to update entry: %v", err)
|
||||
}
|
||||
|
||||
updated, _ := cat.Get(ctx, entry.ID)
|
||||
if updated.SizeBytes != entry.SizeBytes {
|
||||
t.Errorf("Update failed: expected size %d, got %d", entry.SizeBytes, updated.SizeBytes)
|
||||
}
|
||||
|
||||
// Test Search with filters
|
||||
query := &SearchQuery{
|
||||
Database: "testdb",
|
||||
Limit: 10,
|
||||
OrderBy: "created_at",
|
||||
OrderDesc: true,
|
||||
}
|
||||
|
||||
results, err := cat.Search(ctx, query)
|
||||
if err != nil {
|
||||
t.Fatalf("Search failed: %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 1 {
|
||||
t.Errorf("Expected 1 result, got %d", len(results))
|
||||
}
|
||||
|
||||
// Test Search with wildcards
|
||||
query.Database = "test*"
|
||||
results, err = cat.Search(ctx, query)
|
||||
if err != nil {
|
||||
t.Fatalf("Wildcard search failed: %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 1 {
|
||||
t.Errorf("Expected 1 result from wildcard search, got %d", len(results))
|
||||
}
|
||||
|
||||
// Test Count
|
||||
count, err := cat.Count(ctx, &SearchQuery{Database: "testdb"})
|
||||
if err != nil {
|
||||
t.Fatalf("Count failed: %v", err)
|
||||
}
|
||||
|
||||
if count != 1 {
|
||||
t.Errorf("Expected count 1, got %d", count)
|
||||
}
|
||||
|
||||
// Test Delete
|
||||
err = cat.Delete(ctx, entry.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to delete entry: %v", err)
|
||||
}
|
||||
|
||||
deleted, _ := cat.Get(ctx, entry.ID)
|
||||
if deleted != nil {
|
||||
t.Error("Expected entry to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGapDetection(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "catalog_gaps_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbPath := filepath.Join(tmpDir, "test_catalog.db")
|
||||
cat, err := NewSQLiteCatalog(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create catalog: %v", err)
|
||||
}
|
||||
defer cat.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Add backups with varying intervals
|
||||
now := time.Now()
|
||||
backups := []time.Time{
|
||||
now.Add(-7 * 24 * time.Hour), // 7 days ago
|
||||
now.Add(-6 * 24 * time.Hour), // 6 days ago (OK)
|
||||
now.Add(-5 * 24 * time.Hour), // 5 days ago (OK)
|
||||
// Missing 4 days ago - GAP
|
||||
now.Add(-3 * 24 * time.Hour), // 3 days ago
|
||||
now.Add(-2 * 24 * time.Hour), // 2 days ago (OK)
|
||||
// Missing 1 day ago and today - GAP to now
|
||||
}
|
||||
|
||||
for i, ts := range backups {
|
||||
entry := &Entry{
|
||||
Database: "gaptest",
|
||||
DatabaseType: "postgresql",
|
||||
BackupPath: filepath.Join(tmpDir, fmt.Sprintf("backup_%d.dump", i)),
|
||||
BackupType: "full",
|
||||
CreatedAt: ts,
|
||||
Status: StatusCompleted,
|
||||
}
|
||||
cat.Add(ctx, entry)
|
||||
}
|
||||
|
||||
// Detect gaps with 24h expected interval
|
||||
config := &GapDetectionConfig{
|
||||
ExpectedInterval: 24 * time.Hour,
|
||||
Tolerance: 2 * time.Hour,
|
||||
RPOThreshold: 48 * time.Hour,
|
||||
}
|
||||
|
||||
gaps, err := cat.DetectGaps(ctx, "gaptest", config)
|
||||
if err != nil {
|
||||
t.Fatalf("Gap detection failed: %v", err)
|
||||
}
|
||||
|
||||
// Should detect at least 2 gaps:
|
||||
// 1. Between 5 days ago and 3 days ago (missing 4 days ago)
|
||||
// 2. Between 2 days ago and now (missing recent backups)
|
||||
if len(gaps) < 2 {
|
||||
t.Errorf("Expected at least 2 gaps, got %d", len(gaps))
|
||||
}
|
||||
|
||||
// Check gap severities
|
||||
hasCritical := false
|
||||
for _, gap := range gaps {
|
||||
if gap.Severity == SeverityCritical {
|
||||
hasCritical = true
|
||||
}
|
||||
if gap.Duration < config.ExpectedInterval {
|
||||
t.Errorf("Gap duration %v is less than expected interval", gap.Duration)
|
||||
}
|
||||
}
|
||||
|
||||
// The gap from 2 days ago to now should be critical (>48h)
|
||||
if !hasCritical {
|
||||
t.Log("Note: Expected at least one critical gap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSize(t *testing.T) {
|
||||
tests := []struct {
|
||||
bytes int64
|
||||
expected string
|
||||
}{
|
||||
{0, "0 B"},
|
||||
{500, "500 B"},
|
||||
{1024, "1.0 KB"},
|
||||
{1024 * 1024, "1.0 MB"},
|
||||
{1024 * 1024 * 1024, "1.0 GB"},
|
||||
{1024 * 1024 * 1024 * 1024, "1.0 TB"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := FormatSize(test.bytes)
|
||||
if result != test.expected {
|
||||
t.Errorf("FormatSize(%d) = %s, expected %s", test.bytes, result, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDuration(t *testing.T) {
|
||||
tests := []struct {
|
||||
duration time.Duration
|
||||
expected string
|
||||
}{
|
||||
{30 * time.Second, "30s"},
|
||||
{90 * time.Second, "1m 30s"},
|
||||
{2 * time.Hour, "2h 0m"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := FormatDuration(test.duration)
|
||||
if result != test.expected {
|
||||
t.Errorf("FormatDuration(%v) = %s, expected %s", test.duration, result, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
299
internal/catalog/gaps.go
Normal file
299
internal/catalog/gaps.go
Normal file
@@ -0,0 +1,299 @@
|
||||
// Package catalog - Gap detection for backup schedules
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DetectGaps analyzes backup history and finds gaps in the schedule
|
||||
func (c *SQLiteCatalog) DetectGaps(ctx context.Context, database string, config *GapDetectionConfig) ([]*Gap, error) {
|
||||
if config == nil {
|
||||
config = &GapDetectionConfig{
|
||||
ExpectedInterval: 24 * time.Hour,
|
||||
Tolerance: time.Hour,
|
||||
RPOThreshold: 48 * time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
// Get all backups for this database, ordered by time
|
||||
query := &SearchQuery{
|
||||
Database: database,
|
||||
Status: string(StatusCompleted),
|
||||
OrderBy: "created_at",
|
||||
OrderDesc: false,
|
||||
}
|
||||
|
||||
if config.StartDate != nil {
|
||||
query.StartDate = config.StartDate
|
||||
}
|
||||
if config.EndDate != nil {
|
||||
query.EndDate = config.EndDate
|
||||
}
|
||||
|
||||
entries, err := c.Search(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(entries) < 2 {
|
||||
return nil, nil // Not enough backups to detect gaps
|
||||
}
|
||||
|
||||
var gaps []*Gap
|
||||
|
||||
for i := 1; i < len(entries); i++ {
|
||||
prev := entries[i-1]
|
||||
curr := entries[i]
|
||||
|
||||
actualInterval := curr.CreatedAt.Sub(prev.CreatedAt)
|
||||
expectedWithTolerance := config.ExpectedInterval + config.Tolerance
|
||||
|
||||
if actualInterval > expectedWithTolerance {
|
||||
gap := &Gap{
|
||||
Database: database,
|
||||
GapStart: prev.CreatedAt,
|
||||
GapEnd: curr.CreatedAt,
|
||||
Duration: actualInterval,
|
||||
ExpectedAt: prev.CreatedAt.Add(config.ExpectedInterval),
|
||||
}
|
||||
|
||||
// Determine severity
|
||||
if actualInterval > config.RPOThreshold {
|
||||
gap.Severity = SeverityCritical
|
||||
gap.Description = "CRITICAL: Gap exceeds RPO threshold"
|
||||
} else if actualInterval > config.ExpectedInterval*2 {
|
||||
gap.Severity = SeverityWarning
|
||||
gap.Description = "WARNING: Gap exceeds 2x expected interval"
|
||||
} else {
|
||||
gap.Severity = SeverityInfo
|
||||
gap.Description = "INFO: Gap exceeds expected interval"
|
||||
}
|
||||
|
||||
gaps = append(gaps, gap)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for gap from last backup to now
|
||||
lastBackup := entries[len(entries)-1]
|
||||
now := time.Now()
|
||||
if config.EndDate != nil {
|
||||
now = *config.EndDate
|
||||
}
|
||||
|
||||
sinceLastBackup := now.Sub(lastBackup.CreatedAt)
|
||||
if sinceLastBackup > config.ExpectedInterval+config.Tolerance {
|
||||
gap := &Gap{
|
||||
Database: database,
|
||||
GapStart: lastBackup.CreatedAt,
|
||||
GapEnd: now,
|
||||
Duration: sinceLastBackup,
|
||||
ExpectedAt: lastBackup.CreatedAt.Add(config.ExpectedInterval),
|
||||
}
|
||||
|
||||
if sinceLastBackup > config.RPOThreshold {
|
||||
gap.Severity = SeverityCritical
|
||||
gap.Description = "CRITICAL: No backup since " + FormatDuration(sinceLastBackup)
|
||||
} else if sinceLastBackup > config.ExpectedInterval*2 {
|
||||
gap.Severity = SeverityWarning
|
||||
gap.Description = "WARNING: No backup since " + FormatDuration(sinceLastBackup)
|
||||
} else {
|
||||
gap.Severity = SeverityInfo
|
||||
gap.Description = "INFO: Backup overdue by " + FormatDuration(sinceLastBackup-config.ExpectedInterval)
|
||||
}
|
||||
|
||||
gaps = append(gaps, gap)
|
||||
}
|
||||
|
||||
return gaps, nil
|
||||
}
|
||||
|
||||
// DetectAllGaps analyzes all databases for backup gaps
|
||||
func (c *SQLiteCatalog) DetectAllGaps(ctx context.Context, config *GapDetectionConfig) (map[string][]*Gap, error) {
|
||||
databases, err := c.ListDatabases(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allGaps := make(map[string][]*Gap)
|
||||
|
||||
for _, db := range databases {
|
||||
gaps, err := c.DetectGaps(ctx, db, config)
|
||||
if err != nil {
|
||||
continue // Skip errors for individual databases
|
||||
}
|
||||
if len(gaps) > 0 {
|
||||
allGaps[db] = gaps
|
||||
}
|
||||
}
|
||||
|
||||
return allGaps, nil
|
||||
}
|
||||
|
||||
// BackupFrequencyAnalysis provides analysis of backup frequency
|
||||
type BackupFrequencyAnalysis struct {
|
||||
Database string `json:"database"`
|
||||
TotalBackups int `json:"total_backups"`
|
||||
AnalysisPeriod time.Duration `json:"analysis_period"`
|
||||
AverageInterval time.Duration `json:"average_interval"`
|
||||
MinInterval time.Duration `json:"min_interval"`
|
||||
MaxInterval time.Duration `json:"max_interval"`
|
||||
StdDeviation time.Duration `json:"std_deviation"`
|
||||
Regularity float64 `json:"regularity"` // 0-1, higher is more regular
|
||||
GapsDetected int `json:"gaps_detected"`
|
||||
MissedBackups int `json:"missed_backups"` // Estimated based on expected interval
|
||||
}
|
||||
|
||||
// AnalyzeFrequency analyzes backup frequency for a database
|
||||
func (c *SQLiteCatalog) AnalyzeFrequency(ctx context.Context, database string, expectedInterval time.Duration) (*BackupFrequencyAnalysis, error) {
|
||||
query := &SearchQuery{
|
||||
Database: database,
|
||||
Status: string(StatusCompleted),
|
||||
OrderBy: "created_at",
|
||||
OrderDesc: false,
|
||||
}
|
||||
|
||||
entries, err := c.Search(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(entries) < 2 {
|
||||
return &BackupFrequencyAnalysis{
|
||||
Database: database,
|
||||
TotalBackups: len(entries),
|
||||
}, nil
|
||||
}
|
||||
|
||||
analysis := &BackupFrequencyAnalysis{
|
||||
Database: database,
|
||||
TotalBackups: len(entries),
|
||||
}
|
||||
|
||||
// Calculate intervals
|
||||
var intervals []time.Duration
|
||||
for i := 1; i < len(entries); i++ {
|
||||
interval := entries[i].CreatedAt.Sub(entries[i-1].CreatedAt)
|
||||
intervals = append(intervals, interval)
|
||||
}
|
||||
|
||||
analysis.AnalysisPeriod = entries[len(entries)-1].CreatedAt.Sub(entries[0].CreatedAt)
|
||||
|
||||
// Calculate min, max, average
|
||||
sort.Slice(intervals, func(i, j int) bool {
|
||||
return intervals[i] < intervals[j]
|
||||
})
|
||||
|
||||
analysis.MinInterval = intervals[0]
|
||||
analysis.MaxInterval = intervals[len(intervals)-1]
|
||||
|
||||
var total time.Duration
|
||||
for _, interval := range intervals {
|
||||
total += interval
|
||||
}
|
||||
analysis.AverageInterval = total / time.Duration(len(intervals))
|
||||
|
||||
// Calculate standard deviation
|
||||
var sumSquares float64
|
||||
avgNanos := float64(analysis.AverageInterval.Nanoseconds())
|
||||
for _, interval := range intervals {
|
||||
diff := float64(interval.Nanoseconds()) - avgNanos
|
||||
sumSquares += diff * diff
|
||||
}
|
||||
variance := sumSquares / float64(len(intervals))
|
||||
analysis.StdDeviation = time.Duration(int64(variance)) // Simplified
|
||||
|
||||
// Calculate regularity score (lower deviation = higher regularity)
|
||||
if analysis.AverageInterval > 0 {
|
||||
deviationRatio := float64(analysis.StdDeviation) / float64(analysis.AverageInterval)
|
||||
analysis.Regularity = 1.0 - min(deviationRatio, 1.0)
|
||||
}
|
||||
|
||||
// Detect gaps and missed backups
|
||||
config := &GapDetectionConfig{
|
||||
ExpectedInterval: expectedInterval,
|
||||
Tolerance: expectedInterval / 4,
|
||||
RPOThreshold: expectedInterval * 2,
|
||||
}
|
||||
|
||||
gaps, _ := c.DetectGaps(ctx, database, config)
|
||||
analysis.GapsDetected = len(gaps)
|
||||
|
||||
// Estimate missed backups
|
||||
if expectedInterval > 0 {
|
||||
expectedBackups := int(analysis.AnalysisPeriod / expectedInterval)
|
||||
if expectedBackups > analysis.TotalBackups {
|
||||
analysis.MissedBackups = expectedBackups - analysis.TotalBackups
|
||||
}
|
||||
}
|
||||
|
||||
return analysis, nil
|
||||
}
|
||||
|
||||
// RecoveryPointObjective calculates the current RPO status
|
||||
type RPOStatus struct {
|
||||
Database string `json:"database"`
|
||||
LastBackup time.Time `json:"last_backup"`
|
||||
TimeSinceBackup time.Duration `json:"time_since_backup"`
|
||||
TargetRPO time.Duration `json:"target_rpo"`
|
||||
CurrentRPO time.Duration `json:"current_rpo"`
|
||||
RPOMet bool `json:"rpo_met"`
|
||||
NextBackupDue time.Time `json:"next_backup_due"`
|
||||
BackupsIn24Hours int `json:"backups_in_24h"`
|
||||
BackupsIn7Days int `json:"backups_in_7d"`
|
||||
}
|
||||
|
||||
// CalculateRPOStatus calculates RPO status for a database
|
||||
func (c *SQLiteCatalog) CalculateRPOStatus(ctx context.Context, database string, targetRPO time.Duration) (*RPOStatus, error) {
|
||||
status := &RPOStatus{
|
||||
Database: database,
|
||||
TargetRPO: targetRPO,
|
||||
}
|
||||
|
||||
// Get most recent backup
|
||||
entries, err := c.List(ctx, database, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
status.RPOMet = false
|
||||
status.CurrentRPO = time.Duration(0)
|
||||
return status, nil
|
||||
}
|
||||
|
||||
status.LastBackup = entries[0].CreatedAt
|
||||
status.TimeSinceBackup = time.Since(entries[0].CreatedAt)
|
||||
status.CurrentRPO = status.TimeSinceBackup
|
||||
status.RPOMet = status.TimeSinceBackup <= targetRPO
|
||||
status.NextBackupDue = entries[0].CreatedAt.Add(targetRPO)
|
||||
|
||||
// Count backups in time windows
|
||||
now := time.Now()
|
||||
last24h := now.Add(-24 * time.Hour)
|
||||
last7d := now.Add(-7 * 24 * time.Hour)
|
||||
|
||||
count24h, _ := c.Count(ctx, &SearchQuery{
|
||||
Database: database,
|
||||
StartDate: &last24h,
|
||||
Status: string(StatusCompleted),
|
||||
})
|
||||
count7d, _ := c.Count(ctx, &SearchQuery{
|
||||
Database: database,
|
||||
StartDate: &last7d,
|
||||
Status: string(StatusCompleted),
|
||||
})
|
||||
|
||||
status.BackupsIn24Hours = int(count24h)
|
||||
status.BackupsIn7Days = int(count7d)
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func min(a, b float64) float64 {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
632
internal/catalog/sqlite.go
Normal file
632
internal/catalog/sqlite.go
Normal file
@@ -0,0 +1,632 @@
|
||||
// Package catalog - SQLite storage implementation
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// SQLiteCatalog implements Catalog interface with SQLite storage
|
||||
type SQLiteCatalog struct {
|
||||
db *sql.DB
|
||||
path string
|
||||
}
|
||||
|
||||
// NewSQLiteCatalog creates a new SQLite-backed catalog
|
||||
func NewSQLiteCatalog(dbPath string) (*SQLiteCatalog, error) {
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(dbPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create catalog directory: %w", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=ON")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open catalog database: %w", err)
|
||||
}
|
||||
|
||||
catalog := &SQLiteCatalog{
|
||||
db: db,
|
||||
path: dbPath,
|
||||
}
|
||||
|
||||
if err := catalog.initialize(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return catalog, nil
|
||||
}
|
||||
|
||||
// initialize creates the database schema
|
||||
func (c *SQLiteCatalog) initialize() error {
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS backups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
database TEXT NOT NULL,
|
||||
database_type TEXT NOT NULL,
|
||||
host TEXT,
|
||||
port INTEGER,
|
||||
backup_path TEXT NOT NULL UNIQUE,
|
||||
backup_type TEXT DEFAULT 'full',
|
||||
size_bytes INTEGER,
|
||||
sha256 TEXT,
|
||||
compression TEXT,
|
||||
encrypted INTEGER DEFAULT 0,
|
||||
created_at DATETIME NOT NULL,
|
||||
duration REAL,
|
||||
status TEXT DEFAULT 'completed',
|
||||
verified_at DATETIME,
|
||||
verify_valid INTEGER,
|
||||
drill_tested_at DATETIME,
|
||||
drill_success INTEGER,
|
||||
cloud_location TEXT,
|
||||
retention_policy TEXT,
|
||||
tags TEXT,
|
||||
metadata TEXT,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_backups_database ON backups(database);
|
||||
CREATE INDEX IF NOT EXISTS idx_backups_created_at ON backups(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_backups_status ON backups(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_backups_host ON backups(host);
|
||||
CREATE INDEX IF NOT EXISTS idx_backups_database_type ON backups(database_type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS catalog_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Store schema version for migrations
|
||||
INSERT OR IGNORE INTO catalog_meta (key, value) VALUES ('schema_version', '1');
|
||||
`
|
||||
|
||||
_, err := c.db.Exec(schema)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize schema: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add inserts a new backup entry
|
||||
func (c *SQLiteCatalog) Add(ctx context.Context, entry *Entry) error {
|
||||
tagsJSON, _ := json.Marshal(entry.Tags)
|
||||
metaJSON, _ := json.Marshal(entry.Metadata)
|
||||
|
||||
result, err := c.db.ExecContext(ctx, `
|
||||
INSERT INTO backups (
|
||||
database, database_type, host, port, backup_path, backup_type,
|
||||
size_bytes, sha256, compression, encrypted, created_at, duration,
|
||||
status, cloud_location, retention_policy, tags, metadata
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
entry.Database, entry.DatabaseType, entry.Host, entry.Port,
|
||||
entry.BackupPath, entry.BackupType, entry.SizeBytes, entry.SHA256,
|
||||
entry.Compression, entry.Encrypted, entry.CreatedAt, entry.Duration,
|
||||
entry.Status, entry.CloudLocation, entry.RetentionPolicy,
|
||||
string(tagsJSON), string(metaJSON),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add catalog entry: %w", err)
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
entry.ID = id
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update updates an existing backup entry
|
||||
func (c *SQLiteCatalog) Update(ctx context.Context, entry *Entry) error {
|
||||
tagsJSON, _ := json.Marshal(entry.Tags)
|
||||
metaJSON, _ := json.Marshal(entry.Metadata)
|
||||
|
||||
_, err := c.db.ExecContext(ctx, `
|
||||
UPDATE backups SET
|
||||
database = ?, database_type = ?, host = ?, port = ?,
|
||||
backup_type = ?, size_bytes = ?, sha256 = ?, compression = ?,
|
||||
encrypted = ?, duration = ?, status = ?, verified_at = ?,
|
||||
verify_valid = ?, drill_tested_at = ?, drill_success = ?,
|
||||
cloud_location = ?, retention_policy = ?, tags = ?, metadata = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`,
|
||||
entry.Database, entry.DatabaseType, entry.Host, entry.Port,
|
||||
entry.BackupType, entry.SizeBytes, entry.SHA256, entry.Compression,
|
||||
entry.Encrypted, entry.Duration, entry.Status, entry.VerifiedAt,
|
||||
entry.VerifyValid, entry.DrillTestedAt, entry.DrillSuccess,
|
||||
entry.CloudLocation, entry.RetentionPolicy,
|
||||
string(tagsJSON), string(metaJSON), entry.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update catalog entry: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a backup entry
|
||||
func (c *SQLiteCatalog) Delete(ctx context.Context, id int64) error {
|
||||
_, err := c.db.ExecContext(ctx, "DELETE FROM backups WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete catalog entry: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get retrieves a backup entry by ID
|
||||
func (c *SQLiteCatalog) Get(ctx context.Context, id int64) (*Entry, error) {
|
||||
row := c.db.QueryRowContext(ctx, `
|
||||
SELECT id, database, database_type, host, port, backup_path, backup_type,
|
||||
size_bytes, sha256, compression, encrypted, created_at, duration,
|
||||
status, verified_at, verify_valid, drill_tested_at, drill_success,
|
||||
cloud_location, retention_policy, tags, metadata
|
||||
FROM backups WHERE id = ?
|
||||
`, id)
|
||||
|
||||
return c.scanEntry(row)
|
||||
}
|
||||
|
||||
// GetByPath retrieves a backup entry by file path
|
||||
func (c *SQLiteCatalog) GetByPath(ctx context.Context, path string) (*Entry, error) {
|
||||
row := c.db.QueryRowContext(ctx, `
|
||||
SELECT id, database, database_type, host, port, backup_path, backup_type,
|
||||
size_bytes, sha256, compression, encrypted, created_at, duration,
|
||||
status, verified_at, verify_valid, drill_tested_at, drill_success,
|
||||
cloud_location, retention_policy, tags, metadata
|
||||
FROM backups WHERE backup_path = ?
|
||||
`, path)
|
||||
|
||||
return c.scanEntry(row)
|
||||
}
|
||||
|
||||
// scanEntry scans a row into an Entry struct
|
||||
func (c *SQLiteCatalog) scanEntry(row *sql.Row) (*Entry, error) {
|
||||
var entry Entry
|
||||
var tagsJSON, metaJSON sql.NullString
|
||||
var verifiedAt, drillTestedAt sql.NullTime
|
||||
var verifyValid, drillSuccess sql.NullBool
|
||||
|
||||
err := row.Scan(
|
||||
&entry.ID, &entry.Database, &entry.DatabaseType, &entry.Host, &entry.Port,
|
||||
&entry.BackupPath, &entry.BackupType, &entry.SizeBytes, &entry.SHA256,
|
||||
&entry.Compression, &entry.Encrypted, &entry.CreatedAt, &entry.Duration,
|
||||
&entry.Status, &verifiedAt, &verifyValid, &drillTestedAt, &drillSuccess,
|
||||
&entry.CloudLocation, &entry.RetentionPolicy, &tagsJSON, &metaJSON,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan entry: %w", err)
|
||||
}
|
||||
|
||||
if verifiedAt.Valid {
|
||||
entry.VerifiedAt = &verifiedAt.Time
|
||||
}
|
||||
if verifyValid.Valid {
|
||||
entry.VerifyValid = &verifyValid.Bool
|
||||
}
|
||||
if drillTestedAt.Valid {
|
||||
entry.DrillTestedAt = &drillTestedAt.Time
|
||||
}
|
||||
if drillSuccess.Valid {
|
||||
entry.DrillSuccess = &drillSuccess.Bool
|
||||
}
|
||||
|
||||
if tagsJSON.Valid && tagsJSON.String != "" {
|
||||
json.Unmarshal([]byte(tagsJSON.String), &entry.Tags)
|
||||
}
|
||||
if metaJSON.Valid && metaJSON.String != "" {
|
||||
json.Unmarshal([]byte(metaJSON.String), &entry.Metadata)
|
||||
}
|
||||
|
||||
return &entry, nil
|
||||
}
|
||||
|
||||
// Search finds backup entries matching the query
|
||||
func (c *SQLiteCatalog) Search(ctx context.Context, query *SearchQuery) ([]*Entry, error) {
|
||||
where, args := c.buildSearchQuery(query)
|
||||
|
||||
orderBy := "created_at DESC"
|
||||
if query.OrderBy != "" {
|
||||
orderBy = query.OrderBy
|
||||
if query.OrderDesc {
|
||||
orderBy += " DESC"
|
||||
}
|
||||
}
|
||||
|
||||
sql := fmt.Sprintf(`
|
||||
SELECT id, database, database_type, host, port, backup_path, backup_type,
|
||||
size_bytes, sha256, compression, encrypted, created_at, duration,
|
||||
status, verified_at, verify_valid, drill_tested_at, drill_success,
|
||||
cloud_location, retention_policy, tags, metadata
|
||||
FROM backups
|
||||
%s
|
||||
ORDER BY %s
|
||||
`, where, orderBy)
|
||||
|
||||
if query.Limit > 0 {
|
||||
sql += fmt.Sprintf(" LIMIT %d", query.Limit)
|
||||
if query.Offset > 0 {
|
||||
sql += fmt.Sprintf(" OFFSET %d", query.Offset)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := c.db.QueryContext(ctx, sql, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search query failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return c.scanEntries(rows)
|
||||
}
|
||||
|
||||
// scanEntries scans multiple rows into Entry slices
|
||||
func (c *SQLiteCatalog) scanEntries(rows *sql.Rows) ([]*Entry, error) {
|
||||
var entries []*Entry
|
||||
|
||||
for rows.Next() {
|
||||
var entry Entry
|
||||
var tagsJSON, metaJSON sql.NullString
|
||||
var verifiedAt, drillTestedAt sql.NullTime
|
||||
var verifyValid, drillSuccess sql.NullBool
|
||||
|
||||
err := rows.Scan(
|
||||
&entry.ID, &entry.Database, &entry.DatabaseType, &entry.Host, &entry.Port,
|
||||
&entry.BackupPath, &entry.BackupType, &entry.SizeBytes, &entry.SHA256,
|
||||
&entry.Compression, &entry.Encrypted, &entry.CreatedAt, &entry.Duration,
|
||||
&entry.Status, &verifiedAt, &verifyValid, &drillTestedAt, &drillSuccess,
|
||||
&entry.CloudLocation, &entry.RetentionPolicy, &tagsJSON, &metaJSON,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan row: %w", err)
|
||||
}
|
||||
|
||||
if verifiedAt.Valid {
|
||||
entry.VerifiedAt = &verifiedAt.Time
|
||||
}
|
||||
if verifyValid.Valid {
|
||||
entry.VerifyValid = &verifyValid.Bool
|
||||
}
|
||||
if drillTestedAt.Valid {
|
||||
entry.DrillTestedAt = &drillTestedAt.Time
|
||||
}
|
||||
if drillSuccess.Valid {
|
||||
entry.DrillSuccess = &drillSuccess.Bool
|
||||
}
|
||||
|
||||
if tagsJSON.Valid && tagsJSON.String != "" {
|
||||
json.Unmarshal([]byte(tagsJSON.String), &entry.Tags)
|
||||
}
|
||||
if metaJSON.Valid && metaJSON.String != "" {
|
||||
json.Unmarshal([]byte(metaJSON.String), &entry.Metadata)
|
||||
}
|
||||
|
||||
entries = append(entries, &entry)
|
||||
}
|
||||
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
// buildSearchQuery builds the WHERE clause from a SearchQuery
|
||||
func (c *SQLiteCatalog) buildSearchQuery(query *SearchQuery) (string, []interface{}) {
|
||||
var conditions []string
|
||||
var args []interface{}
|
||||
|
||||
if query.Database != "" {
|
||||
if strings.Contains(query.Database, "*") {
|
||||
conditions = append(conditions, "database LIKE ?")
|
||||
args = append(args, strings.ReplaceAll(query.Database, "*", "%"))
|
||||
} else {
|
||||
conditions = append(conditions, "database = ?")
|
||||
args = append(args, query.Database)
|
||||
}
|
||||
}
|
||||
|
||||
if query.DatabaseType != "" {
|
||||
conditions = append(conditions, "database_type = ?")
|
||||
args = append(args, query.DatabaseType)
|
||||
}
|
||||
|
||||
if query.Host != "" {
|
||||
conditions = append(conditions, "host = ?")
|
||||
args = append(args, query.Host)
|
||||
}
|
||||
|
||||
if query.Status != "" {
|
||||
conditions = append(conditions, "status = ?")
|
||||
args = append(args, query.Status)
|
||||
}
|
||||
|
||||
if query.StartDate != nil {
|
||||
conditions = append(conditions, "created_at >= ?")
|
||||
args = append(args, *query.StartDate)
|
||||
}
|
||||
|
||||
if query.EndDate != nil {
|
||||
conditions = append(conditions, "created_at <= ?")
|
||||
args = append(args, *query.EndDate)
|
||||
}
|
||||
|
||||
if query.MinSize > 0 {
|
||||
conditions = append(conditions, "size_bytes >= ?")
|
||||
args = append(args, query.MinSize)
|
||||
}
|
||||
|
||||
if query.MaxSize > 0 {
|
||||
conditions = append(conditions, "size_bytes <= ?")
|
||||
args = append(args, query.MaxSize)
|
||||
}
|
||||
|
||||
if query.BackupType != "" {
|
||||
conditions = append(conditions, "backup_type = ?")
|
||||
args = append(args, query.BackupType)
|
||||
}
|
||||
|
||||
if query.Encrypted != nil {
|
||||
conditions = append(conditions, "encrypted = ?")
|
||||
args = append(args, *query.Encrypted)
|
||||
}
|
||||
|
||||
if query.Verified != nil {
|
||||
if *query.Verified {
|
||||
conditions = append(conditions, "verified_at IS NOT NULL AND verify_valid = 1")
|
||||
} else {
|
||||
conditions = append(conditions, "verified_at IS NULL")
|
||||
}
|
||||
}
|
||||
|
||||
if query.DrillTested != nil {
|
||||
if *query.DrillTested {
|
||||
conditions = append(conditions, "drill_tested_at IS NOT NULL AND drill_success = 1")
|
||||
} else {
|
||||
conditions = append(conditions, "drill_tested_at IS NULL")
|
||||
}
|
||||
}
|
||||
|
||||
if len(conditions) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return "WHERE " + strings.Join(conditions, " AND "), args
|
||||
}
|
||||
|
||||
// List returns recent backups for a database
|
||||
func (c *SQLiteCatalog) List(ctx context.Context, database string, limit int) ([]*Entry, error) {
|
||||
query := &SearchQuery{
|
||||
Database: database,
|
||||
Limit: limit,
|
||||
OrderBy: "created_at",
|
||||
OrderDesc: true,
|
||||
}
|
||||
return c.Search(ctx, query)
|
||||
}
|
||||
|
||||
// ListDatabases returns all unique database names
|
||||
func (c *SQLiteCatalog) ListDatabases(ctx context.Context) ([]string, error) {
|
||||
rows, err := c.db.QueryContext(ctx, "SELECT DISTINCT database FROM backups ORDER BY database")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list databases: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var databases []string
|
||||
for rows.Next() {
|
||||
var db string
|
||||
if err := rows.Scan(&db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
databases = append(databases, db)
|
||||
}
|
||||
|
||||
return databases, rows.Err()
|
||||
}
|
||||
|
||||
// Count returns the number of entries matching the query
|
||||
func (c *SQLiteCatalog) Count(ctx context.Context, query *SearchQuery) (int64, error) {
|
||||
where, args := c.buildSearchQuery(query)
|
||||
|
||||
sql := "SELECT COUNT(*) FROM backups " + where
|
||||
|
||||
var count int64
|
||||
err := c.db.QueryRowContext(ctx, sql, args...).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count query failed: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// Stats returns overall catalog statistics
|
||||
func (c *SQLiteCatalog) Stats(ctx context.Context) (*Stats, error) {
|
||||
stats := &Stats{
|
||||
ByDatabase: make(map[string]int64),
|
||||
ByType: make(map[string]int64),
|
||||
ByStatus: make(map[string]int64),
|
||||
}
|
||||
|
||||
// Basic stats
|
||||
row := c.db.QueryRowContext(ctx, `
|
||||
SELECT
|
||||
COUNT(*),
|
||||
COALESCE(SUM(size_bytes), 0),
|
||||
MIN(created_at),
|
||||
MAX(created_at),
|
||||
COALESCE(AVG(duration), 0),
|
||||
CAST(COALESCE(AVG(size_bytes), 0) AS INTEGER),
|
||||
SUM(CASE WHEN verified_at IS NOT NULL THEN 1 ELSE 0 END),
|
||||
SUM(CASE WHEN drill_tested_at IS NOT NULL THEN 1 ELSE 0 END)
|
||||
FROM backups WHERE status != 'deleted'
|
||||
`)
|
||||
|
||||
var oldest, newest sql.NullString
|
||||
err := row.Scan(
|
||||
&stats.TotalBackups, &stats.TotalSize, &oldest, &newest,
|
||||
&stats.AvgDuration, &stats.AvgSize,
|
||||
&stats.VerifiedCount, &stats.DrillTestedCount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get stats: %w", err)
|
||||
}
|
||||
|
||||
if oldest.Valid {
|
||||
if t, err := time.Parse(time.RFC3339Nano, oldest.String); err == nil {
|
||||
stats.OldestBackup = &t
|
||||
} else if t, err := time.Parse("2006-01-02 15:04:05.999999999-07:00", oldest.String); err == nil {
|
||||
stats.OldestBackup = &t
|
||||
} else if t, err := time.Parse("2006-01-02T15:04:05Z", oldest.String); err == nil {
|
||||
stats.OldestBackup = &t
|
||||
}
|
||||
}
|
||||
if newest.Valid {
|
||||
if t, err := time.Parse(time.RFC3339Nano, newest.String); err == nil {
|
||||
stats.NewestBackup = &t
|
||||
} else if t, err := time.Parse("2006-01-02 15:04:05.999999999-07:00", newest.String); err == nil {
|
||||
stats.NewestBackup = &t
|
||||
} else if t, err := time.Parse("2006-01-02T15:04:05Z", newest.String); err == nil {
|
||||
stats.NewestBackup = &t
|
||||
}
|
||||
}
|
||||
stats.TotalSizeHuman = FormatSize(stats.TotalSize)
|
||||
|
||||
// By database
|
||||
rows, _ := c.db.QueryContext(ctx, "SELECT database, COUNT(*) FROM backups GROUP BY database")
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var db string
|
||||
var count int64
|
||||
rows.Scan(&db, &count)
|
||||
stats.ByDatabase[db] = count
|
||||
}
|
||||
|
||||
// By type
|
||||
rows, _ = c.db.QueryContext(ctx, "SELECT backup_type, COUNT(*) FROM backups GROUP BY backup_type")
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var t string
|
||||
var count int64
|
||||
rows.Scan(&t, &count)
|
||||
stats.ByType[t] = count
|
||||
}
|
||||
|
||||
// By status
|
||||
rows, _ = c.db.QueryContext(ctx, "SELECT status, COUNT(*) FROM backups GROUP BY status")
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var s string
|
||||
var count int64
|
||||
rows.Scan(&s, &count)
|
||||
stats.ByStatus[s] = count
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// StatsByDatabase returns statistics for a specific database
|
||||
func (c *SQLiteCatalog) StatsByDatabase(ctx context.Context, database string) (*Stats, error) {
|
||||
stats := &Stats{
|
||||
ByDatabase: make(map[string]int64),
|
||||
ByType: make(map[string]int64),
|
||||
ByStatus: make(map[string]int64),
|
||||
}
|
||||
|
||||
row := c.db.QueryRowContext(ctx, `
|
||||
SELECT
|
||||
COUNT(*),
|
||||
COALESCE(SUM(size_bytes), 0),
|
||||
MIN(created_at),
|
||||
MAX(created_at),
|
||||
COALESCE(AVG(duration), 0),
|
||||
COALESCE(AVG(size_bytes), 0),
|
||||
SUM(CASE WHEN verified_at IS NOT NULL THEN 1 ELSE 0 END),
|
||||
SUM(CASE WHEN drill_tested_at IS NOT NULL THEN 1 ELSE 0 END)
|
||||
FROM backups WHERE database = ? AND status != 'deleted'
|
||||
`, database)
|
||||
|
||||
var oldest, newest sql.NullTime
|
||||
err := row.Scan(
|
||||
&stats.TotalBackups, &stats.TotalSize, &oldest, &newest,
|
||||
&stats.AvgDuration, &stats.AvgSize,
|
||||
&stats.VerifiedCount, &stats.DrillTestedCount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get database stats: %w", err)
|
||||
}
|
||||
|
||||
if oldest.Valid {
|
||||
stats.OldestBackup = &oldest.Time
|
||||
}
|
||||
if newest.Valid {
|
||||
stats.NewestBackup = &newest.Time
|
||||
}
|
||||
stats.TotalSizeHuman = FormatSize(stats.TotalSize)
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// MarkVerified updates the verification status of a backup
|
||||
func (c *SQLiteCatalog) MarkVerified(ctx context.Context, id int64, valid bool) error {
|
||||
status := StatusVerified
|
||||
if !valid {
|
||||
status = StatusCorrupted
|
||||
}
|
||||
|
||||
_, err := c.db.ExecContext(ctx, `
|
||||
UPDATE backups SET
|
||||
verified_at = CURRENT_TIMESTAMP,
|
||||
verify_valid = ?,
|
||||
status = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`, valid, status, id)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkDrillTested updates the drill test status of a backup
|
||||
func (c *SQLiteCatalog) MarkDrillTested(ctx context.Context, id int64, success bool) error {
|
||||
_, err := c.db.ExecContext(ctx, `
|
||||
UPDATE backups SET
|
||||
drill_tested_at = CURRENT_TIMESTAMP,
|
||||
drill_success = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`, success, id)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Prune removes entries older than the given time
|
||||
func (c *SQLiteCatalog) Prune(ctx context.Context, before time.Time) (int, error) {
|
||||
result, err := c.db.ExecContext(ctx,
|
||||
"DELETE FROM backups WHERE created_at < ? AND status = 'deleted'",
|
||||
before,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("prune failed: %w", err)
|
||||
}
|
||||
|
||||
affected, _ := result.RowsAffected()
|
||||
return int(affected), nil
|
||||
}
|
||||
|
||||
// Vacuum optimizes the database
|
||||
func (c *SQLiteCatalog) Vacuum(ctx context.Context) error {
|
||||
_, err := c.db.ExecContext(ctx, "VACUUM")
|
||||
return err
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func (c *SQLiteCatalog) Close() error {
|
||||
return c.db.Close()
|
||||
}
|
||||
234
internal/catalog/sync.go
Normal file
234
internal/catalog/sync.go
Normal file
@@ -0,0 +1,234 @@
|
||||
// Package catalog - Sync functionality for importing backups into catalog
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"dbbackup/internal/metadata"
|
||||
)
|
||||
|
||||
// SyncFromDirectory scans a directory and imports backup metadata into the catalog
|
||||
func (c *SQLiteCatalog) SyncFromDirectory(ctx context.Context, dir string) (*SyncResult, error) {
|
||||
start := time.Now()
|
||||
result := &SyncResult{}
|
||||
|
||||
// Find all metadata files
|
||||
pattern := filepath.Join(dir, "*.meta.json")
|
||||
matches, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan directory: %w", err)
|
||||
}
|
||||
|
||||
// Also check subdirectories
|
||||
subPattern := filepath.Join(dir, "*", "*.meta.json")
|
||||
subMatches, _ := filepath.Glob(subPattern)
|
||||
matches = append(matches, subMatches...)
|
||||
|
||||
for _, metaPath := range matches {
|
||||
// Derive backup file path from metadata path
|
||||
backupPath := strings.TrimSuffix(metaPath, ".meta.json")
|
||||
|
||||
// Check if backup file exists
|
||||
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
|
||||
result.Details = append(result.Details,
|
||||
fmt.Sprintf("SKIP: %s (backup file missing)", filepath.Base(backupPath)))
|
||||
continue
|
||||
}
|
||||
|
||||
// Load metadata
|
||||
meta, err := metadata.Load(backupPath)
|
||||
if err != nil {
|
||||
result.Errors++
|
||||
result.Details = append(result.Details,
|
||||
fmt.Sprintf("ERROR: %s - %v", filepath.Base(backupPath), err))
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if already in catalog
|
||||
existing, _ := c.GetByPath(ctx, backupPath)
|
||||
if existing != nil {
|
||||
// Update if metadata changed
|
||||
if existing.SHA256 != meta.SHA256 || existing.SizeBytes != meta.SizeBytes {
|
||||
entry := metadataToEntry(meta, backupPath)
|
||||
entry.ID = existing.ID
|
||||
if err := c.Update(ctx, entry); err != nil {
|
||||
result.Errors++
|
||||
result.Details = append(result.Details,
|
||||
fmt.Sprintf("ERROR updating: %s - %v", filepath.Base(backupPath), err))
|
||||
} else {
|
||||
result.Updated++
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Add new entry
|
||||
entry := metadataToEntry(meta, backupPath)
|
||||
if err := c.Add(ctx, entry); err != nil {
|
||||
result.Errors++
|
||||
result.Details = append(result.Details,
|
||||
fmt.Sprintf("ERROR adding: %s - %v", filepath.Base(backupPath), err))
|
||||
} else {
|
||||
result.Added++
|
||||
result.Details = append(result.Details,
|
||||
fmt.Sprintf("ADDED: %s (%s)", filepath.Base(backupPath), FormatSize(meta.SizeBytes)))
|
||||
}
|
||||
}
|
||||
|
||||
// Check for removed backups (backups in catalog but not on disk)
|
||||
entries, _ := c.Search(ctx, &SearchQuery{})
|
||||
for _, entry := range entries {
|
||||
if !strings.HasPrefix(entry.BackupPath, dir) {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(entry.BackupPath); os.IsNotExist(err) {
|
||||
// Mark as deleted
|
||||
entry.Status = StatusDeleted
|
||||
c.Update(ctx, entry)
|
||||
result.Removed++
|
||||
result.Details = append(result.Details,
|
||||
fmt.Sprintf("REMOVED: %s (file not found)", filepath.Base(entry.BackupPath)))
|
||||
}
|
||||
}
|
||||
|
||||
result.Duration = time.Since(start).Seconds()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SyncFromCloud imports backups from cloud storage
|
||||
func (c *SQLiteCatalog) SyncFromCloud(ctx context.Context, provider, bucket, prefix string) (*SyncResult, error) {
|
||||
// This will be implemented when integrating with cloud package
|
||||
// For now, return a placeholder
|
||||
return &SyncResult{
|
||||
Details: []string{"Cloud sync not yet implemented - use directory sync instead"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// metadataToEntry converts backup metadata to a catalog entry
|
||||
func metadataToEntry(meta *metadata.BackupMetadata, backupPath string) *Entry {
|
||||
entry := &Entry{
|
||||
Database: meta.Database,
|
||||
DatabaseType: meta.DatabaseType,
|
||||
Host: meta.Host,
|
||||
Port: meta.Port,
|
||||
BackupPath: backupPath,
|
||||
BackupType: meta.BackupType,
|
||||
SizeBytes: meta.SizeBytes,
|
||||
SHA256: meta.SHA256,
|
||||
Compression: meta.Compression,
|
||||
Encrypted: meta.Encrypted,
|
||||
CreatedAt: meta.Timestamp,
|
||||
Duration: meta.Duration,
|
||||
Status: StatusCompleted,
|
||||
Metadata: meta.ExtraInfo,
|
||||
}
|
||||
|
||||
if entry.BackupType == "" {
|
||||
entry.BackupType = "full"
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
// ImportEntry creates a catalog entry directly from backup file info
|
||||
func (c *SQLiteCatalog) ImportEntry(ctx context.Context, backupPath string, info os.FileInfo, dbName, dbType string) error {
|
||||
entry := &Entry{
|
||||
Database: dbName,
|
||||
DatabaseType: dbType,
|
||||
BackupPath: backupPath,
|
||||
BackupType: "full",
|
||||
SizeBytes: info.Size(),
|
||||
CreatedAt: info.ModTime(),
|
||||
Status: StatusCompleted,
|
||||
}
|
||||
|
||||
// Detect compression from extension
|
||||
switch {
|
||||
case strings.HasSuffix(backupPath, ".gz"):
|
||||
entry.Compression = "gzip"
|
||||
case strings.HasSuffix(backupPath, ".lz4"):
|
||||
entry.Compression = "lz4"
|
||||
case strings.HasSuffix(backupPath, ".zst"):
|
||||
entry.Compression = "zstd"
|
||||
}
|
||||
|
||||
// Check if encrypted
|
||||
if strings.Contains(backupPath, ".enc") {
|
||||
entry.Encrypted = true
|
||||
}
|
||||
|
||||
// Try to load metadata if exists
|
||||
if meta, err := metadata.Load(backupPath); err == nil {
|
||||
entry.SHA256 = meta.SHA256
|
||||
entry.Duration = meta.Duration
|
||||
entry.Host = meta.Host
|
||||
entry.Port = meta.Port
|
||||
entry.Metadata = meta.ExtraInfo
|
||||
}
|
||||
|
||||
return c.Add(ctx, entry)
|
||||
}
|
||||
|
||||
// SyncStatus returns the sync status summary
|
||||
type SyncStatus struct {
|
||||
LastSync *time.Time `json:"last_sync,omitempty"`
|
||||
TotalEntries int64 `json:"total_entries"`
|
||||
ActiveEntries int64 `json:"active_entries"`
|
||||
DeletedEntries int64 `json:"deleted_entries"`
|
||||
Directories []string `json:"directories"`
|
||||
}
|
||||
|
||||
// GetSyncStatus returns the current sync status
|
||||
func (c *SQLiteCatalog) GetSyncStatus(ctx context.Context) (*SyncStatus, error) {
|
||||
status := &SyncStatus{}
|
||||
|
||||
// Get last sync time
|
||||
var lastSync sql.NullString
|
||||
c.db.QueryRowContext(ctx, "SELECT value FROM catalog_meta WHERE key = 'last_sync'").Scan(&lastSync)
|
||||
if lastSync.Valid {
|
||||
if t, err := time.Parse(time.RFC3339, lastSync.String); err == nil {
|
||||
status.LastSync = &t
|
||||
}
|
||||
}
|
||||
|
||||
// Count entries
|
||||
c.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM backups").Scan(&status.TotalEntries)
|
||||
c.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM backups WHERE status != 'deleted'").Scan(&status.ActiveEntries)
|
||||
c.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM backups WHERE status = 'deleted'").Scan(&status.DeletedEntries)
|
||||
|
||||
// Get unique directories
|
||||
rows, _ := c.db.QueryContext(ctx, `
|
||||
SELECT DISTINCT
|
||||
CASE
|
||||
WHEN instr(backup_path, '/') > 0
|
||||
THEN substr(backup_path, 1, length(backup_path) - length(replace(backup_path, '/', '')) - length(substr(backup_path, length(backup_path) - length(replace(backup_path, '/', '')) + 2)))
|
||||
ELSE backup_path
|
||||
END as dir
|
||||
FROM backups WHERE status != 'deleted'
|
||||
`)
|
||||
if rows != nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var dir string
|
||||
rows.Scan(&dir)
|
||||
status.Directories = append(status.Directories, dir)
|
||||
}
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// SetLastSync updates the last sync timestamp
|
||||
func (c *SQLiteCatalog) SetLastSync(ctx context.Context) error {
|
||||
_, err := c.db.ExecContext(ctx, `
|
||||
INSERT OR REPLACE INTO catalog_meta (key, value, updated_at)
|
||||
VALUES ('last_sync', ?, CURRENT_TIMESTAMP)
|
||||
`, time.Now().Format(time.RFC3339))
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user