Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b5239d839d | |||
| fab48ac564 | |||
| 66865a5fb8 | |||
| f9dd95520b | |||
| ac1c892d9b | |||
| 084f7b3938 | |||
| 173b2ce035 | |||
| efe9457aa4 | |||
| e2284f295a | |||
| 9e3270dc10 | |||
| fd0bf52479 | |||
| aeed1dec43 | |||
| 015325323a | |||
| 2724a542d8 | |||
| a09d5d672c | |||
| 5792ce883c | |||
| 2fb38ba366 | |||
| 7aa284723e | |||
| 8d843f412f | |||
| ab2f89608e | |||
| 0178abdadb | |||
| 7da88c343f | |||
| fd989f4b21 | |||
| 9e98d6fb8d | |||
| 56bb128fdb | |||
| eac79baad6 | |||
| c655076ecd |
238
CHANGELOG.md
238
CHANGELOG.md
@ -5,6 +5,244 @@ All notable changes to dbbackup will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [4.2.9] - 2026-01-30
|
||||
|
||||
### Added - MEDIUM Priority Features
|
||||
|
||||
- **#11: Enhanced Error Diagnostics with System Context (MEDIUM priority)**
|
||||
- Automatic environmental context collection on errors
|
||||
- Real-time system diagnostics: disk space, memory, file descriptors
|
||||
- PostgreSQL diagnostics: connections, locks, shared memory, version
|
||||
- Smart root cause analysis based on error + environment
|
||||
- Context-specific recommendations (e.g., "Disk 95% full" → cleanup commands)
|
||||
- Comprehensive diagnostics report with actionable fixes
|
||||
- **Problem**: Errors showed symptoms but not environmental causes
|
||||
- **Solution**: Diagnose system state + error pattern → root cause + fix
|
||||
|
||||
**Diagnostic Report Includes:**
|
||||
- Disk space usage and available capacity
|
||||
- Memory usage and pressure indicators
|
||||
- File descriptor utilization (Linux/Unix)
|
||||
- PostgreSQL connection pool status
|
||||
- Lock table capacity calculations
|
||||
- Version compatibility checks
|
||||
- Contextual recommendations based on actual system state
|
||||
|
||||
**Example Diagnostics:**
|
||||
```
|
||||
═══════════════════════════════════════════════════════════
|
||||
DBBACKUP ERROR DIAGNOSTICS REPORT
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
Error Type: CRITICAL
|
||||
Category: locks
|
||||
Severity: 2/3
|
||||
|
||||
Message:
|
||||
out of shared memory: max_locks_per_transaction exceeded
|
||||
|
||||
Root Cause:
|
||||
Lock table capacity too low (32,000 total locks). Likely cause:
|
||||
max_locks_per_transaction (128) too low for this database size
|
||||
|
||||
System Context:
|
||||
Disk Space: 45.3 GB / 100.0 GB (45.3% used)
|
||||
Memory: 3.2 GB / 8.0 GB (40.0% used)
|
||||
File Descriptors: 234 / 4096
|
||||
|
||||
Database Context:
|
||||
Version: PostgreSQL 14.10
|
||||
Connections: 15 / 100
|
||||
Max Locks: 128 per transaction
|
||||
Total Lock Capacity: ~12,800
|
||||
|
||||
Recommendations:
|
||||
Current lock capacity: 12,800 locks (max_locks_per_transaction × max_connections)
|
||||
⚠ max_locks_per_transaction is low (128)
|
||||
• Increase: ALTER SYSTEM SET max_locks_per_transaction = 4096;
|
||||
• Then restart PostgreSQL: sudo systemctl restart postgresql
|
||||
|
||||
Suggested Action:
|
||||
Fix: ALTER SYSTEM SET max_locks_per_transaction = 4096; then
|
||||
RESTART PostgreSQL
|
||||
```
|
||||
|
||||
**Functions:**
|
||||
- `GatherErrorContext()` - Collects system + database metrics
|
||||
- `DiagnoseError()` - Full error analysis with environmental context
|
||||
- `FormatDiagnosticsReport()` - Human-readable report generation
|
||||
- `generateContextualRecommendations()` - Smart recommendations based on state
|
||||
- `analyzeRootCause()` - Pattern matching for root cause identification
|
||||
|
||||
**Integration:**
|
||||
- Available for all backup/restore operations
|
||||
- Automatic context collection on critical errors
|
||||
- Can be manually triggered for troubleshooting
|
||||
- Export as JSON for automated monitoring
|
||||
|
||||
## [4.2.8] - 2026-01-30
|
||||
|
||||
### Added - MEDIUM Priority Features
|
||||
|
||||
- **#10: WAL Archive Statistics (MEDIUM priority)**
|
||||
- `dbbackup pitr status` now shows comprehensive WAL archive statistics
|
||||
- Displays: total files, total size, compression rate, oldest/newest WAL, time span
|
||||
- Auto-detects archive directory from PostgreSQL `archive_command`
|
||||
- Supports compressed (.gz, .zst, .lz4) and encrypted (.enc) WAL files
|
||||
- **Problem**: No visibility into WAL archive health and growth
|
||||
- **Solution**: Real-time stats in PITR status command, helps identify retention issues
|
||||
|
||||
**Example Output:**
|
||||
```
|
||||
WAL Archive Statistics:
|
||||
======================================================
|
||||
Total Files: 1,234
|
||||
Total Size: 19.8 GB
|
||||
Average Size: 16.4 MB
|
||||
Compressed: 1,234 files (68.5% saved)
|
||||
Encrypted: 1,234 files
|
||||
|
||||
Oldest WAL: 000000010000000000000042
|
||||
Created: 2026-01-15 08:30:00
|
||||
Newest WAL: 000000010000000000004D2F
|
||||
Created: 2026-01-30 17:45:30
|
||||
Time Span: 15.4 days
|
||||
```
|
||||
|
||||
**Files Modified:**
|
||||
- `internal/wal/archiver.go`: Extended `ArchiveStats` struct with detailed fields
|
||||
- `internal/wal/archiver.go`: Added `GetArchiveStats()`, `FormatArchiveStats()` functions
|
||||
- `cmd/pitr.go`: Integrated stats into `pitr status` command
|
||||
- `cmd/pitr.go`: Added `extractArchiveDirFromCommand()` helper
|
||||
|
||||
## [4.2.7] - 2026-01-30
|
||||
|
||||
### Added - HIGH Priority Features
|
||||
|
||||
- **#9: Auto Backup Verification (HIGH priority)**
|
||||
- Automatic integrity verification after every backup (default: ON)
|
||||
- Single DB backups: Full SHA-256 checksum verification
|
||||
- Cluster backups: Quick tar.gz structure validation (header scan)
|
||||
- Prevents corrupted backups from being stored undetected
|
||||
- Can disable with `--no-verify` flag or `VERIFY_AFTER_BACKUP=false`
|
||||
- Performance overhead: +5-10% for single DB, +1-2% for cluster
|
||||
- **Problem**: Backups not verified until restore time (too late to fix)
|
||||
- **Solution**: Immediate feedback on backup integrity, fail-fast on corruption
|
||||
|
||||
### Fixed - Performance & Reliability
|
||||
|
||||
- **#5: TUI Memory Leak in Long Operations (HIGH priority)**
|
||||
- Throttled progress speed samples to max 10 updates/second (100ms intervals)
|
||||
- Fixed memory bloat during large cluster restores (100+ databases)
|
||||
- Reduced memory usage by ~90% in long-running operations
|
||||
- No visual degradation (10 FPS is smooth enough for progress display)
|
||||
- Applied to: `internal/tui/restore_exec.go`, `internal/tui/detailed_progress.go`
|
||||
- **Problem**: Progress callbacks fired on every 4KB buffer read = millions of allocations
|
||||
- **Solution**: Throttle sample collection to prevent unbounded array growth
|
||||
|
||||
## [4.2.5] - 2026-01-30
|
||||
## [4.2.6] - 2026-01-30
|
||||
|
||||
### Security - Critical Fixes
|
||||
|
||||
- **SEC#1: Password exposure in process list**
|
||||
- Removed `--password` CLI flag to prevent passwords appearing in `ps aux`
|
||||
- Use environment variables (`PGPASSWORD`, `MYSQL_PWD`) or config file instead
|
||||
- Enhanced security for multi-user systems and shared environments
|
||||
|
||||
- **SEC#2: World-readable backup files**
|
||||
- All backup files now created with 0600 permissions (owner-only read/write)
|
||||
- Prevents unauthorized users from reading sensitive database dumps
|
||||
- Affects: `internal/backup/engine.go`, `incremental_mysql.go`, `incremental_tar.go`
|
||||
- Critical for GDPR, HIPAA, and PCI-DSS compliance
|
||||
|
||||
- **#4: Directory race condition in parallel backups**
|
||||
- Replaced `os.MkdirAll()` with `fs.SecureMkdirAll()` that handles EEXIST gracefully
|
||||
- Prevents "file exists" errors when multiple backup processes create directories
|
||||
- Affects: All backup directory creation paths
|
||||
|
||||
### Added
|
||||
|
||||
- **internal/fs/secure.go**: New secure file operations utilities
|
||||
- `SecureMkdirAll()`: Race-condition-safe directory creation
|
||||
- `SecureCreate()`: File creation with 0600 permissions
|
||||
- `SecureMkdirTemp()`: Temporary directories with 0700 permissions
|
||||
- `CheckWriteAccess()`: Proactive detection of read-only filesystems
|
||||
|
||||
- **internal/exitcode/codes.go**: BSD-style exit codes for automation
|
||||
- Standard exit codes for scripting and monitoring systems
|
||||
- Improves integration with systemd, cron, and orchestration tools
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed multiple file creation calls using insecure 0644 permissions
|
||||
- Fixed race conditions in backup directory creation during parallel operations
|
||||
- Improved security posture for multi-user and shared environments
|
||||
|
||||
|
||||
### Fixed - TUI Cluster Restore Double-Extraction
|
||||
|
||||
- **TUI cluster restore performance optimization**
|
||||
- Eliminated double-extraction: cluster archives were scanned twice (once for DB list, once for restore)
|
||||
- `internal/restore/extract.go`: Added `ListDatabasesFromExtractedDir()` to list databases from disk instead of tar scan
|
||||
- `internal/tui/cluster_db_selector.go`: Now pre-extracts cluster once, lists from extracted directory
|
||||
- `internal/tui/archive_browser.go`: Added `ExtractedDir` field to `ArchiveInfo` for passing pre-extracted path
|
||||
- `internal/tui/restore_exec.go`: Reuses pre-extracted directory when available
|
||||
- **Performance improvement:** 50GB cluster archive now processes once instead of twice (saves 5-15 minutes)
|
||||
- Automatic cleanup of extracted directory after restore completes or fails
|
||||
|
||||
## [4.2.4] - 2026-01-30
|
||||
|
||||
### Fixed - Comprehensive Ctrl+C Support Across All Operations
|
||||
|
||||
- **System-wide context-aware file operations**
|
||||
- All long-running I/O operations now respond to Ctrl+C
|
||||
- Added `CopyWithContext()` to cloud package for S3/Azure/GCS transfers
|
||||
- Partial files are cleaned up on cancellation
|
||||
|
||||
- **Fixed components:**
|
||||
- `internal/restore/extract.go`: Single DB extraction from cluster
|
||||
- `internal/wal/compression.go`: WAL file compression/decompression
|
||||
- `internal/restore/engine.go`: SQL restore streaming (2 paths)
|
||||
- `internal/backup/engine.go`: pg_dump/mysqldump streaming (3 paths)
|
||||
- `internal/cloud/s3.go`: S3 download interruption
|
||||
- `internal/cloud/azure.go`: Azure Blob download interruption
|
||||
- `internal/cloud/gcs.go`: GCS upload/download interruption
|
||||
- `internal/drill/engine.go`: DR drill decompression
|
||||
|
||||
## [4.2.3] - 2026-01-30
|
||||
|
||||
### Fixed - Cluster Restore Performance & Ctrl+C Handling
|
||||
|
||||
- **Removed redundant gzip validation in cluster restore**
|
||||
- `ValidateAndExtractCluster()` no longer calls `ValidateArchive()` internally
|
||||
- Previously validation happened 2x before extraction (caller + internal)
|
||||
- Eliminates duplicate gzip header reads on large archives
|
||||
- Reduces cluster restore startup time
|
||||
|
||||
- **Fixed Ctrl+C not working during extraction**
|
||||
- Added `CopyWithContext()` function for context-aware file copying
|
||||
- Extraction now checks for cancellation every 1MB of data
|
||||
- Ctrl+C immediately interrupts large file extractions
|
||||
- Partial files are cleaned up on cancellation
|
||||
- Applies to both `ExtractTarGzParallel` and `extractArchiveWithProgress`
|
||||
|
||||
## [4.2.2] - 2026-01-30
|
||||
|
||||
### Fixed - Complete pgzip Migration (Backup Side)
|
||||
|
||||
- **Removed ALL external gzip/pigz calls from backup engine**
|
||||
- `internal/backup/engine.go`: `executeWithStreamingCompression` now uses pgzip
|
||||
- `internal/parallel/engine.go`: Fixed stub gzipWriter to use pgzip
|
||||
- No more gzip/pigz processes visible in htop during backup
|
||||
- Uses klauspost/pgzip for parallel multi-core compression
|
||||
|
||||
- **Complete pgzip migration status**:
|
||||
- ✅ Backup: All compression uses in-process pgzip
|
||||
- ✅ Restore: All decompression uses in-process pgzip
|
||||
- ✅ Drill: Decompress on host with pgzip before Docker copy
|
||||
- ⚠️ PITR only: PostgreSQL's `restore_command` must remain shell (PostgreSQL limitation)
|
||||
|
||||
## [4.2.1] - 2026-01-30
|
||||
|
||||
### Fixed - Complete pgzip Migration
|
||||
|
||||
@ -129,6 +129,11 @@ func init() {
|
||||
cmd.Flags().BoolVarP(&backupDryRun, "dry-run", "n", false, "Validate configuration without executing backup")
|
||||
}
|
||||
|
||||
// Verification flag for all backup commands (HIGH priority #9)
|
||||
for _, cmd := range []*cobra.Command{clusterCmd, singleCmd, sampleCmd} {
|
||||
cmd.Flags().Bool("no-verify", false, "Skip automatic backup verification after creation")
|
||||
}
|
||||
|
||||
// Cloud storage flags for all backup commands
|
||||
for _, cmd := range []*cobra.Command{clusterCmd, singleCmd, sampleCmd} {
|
||||
cmd.Flags().String("cloud", "", "Cloud storage URI (e.g., s3://bucket/path) - takes precedence over individual flags")
|
||||
@ -184,6 +189,12 @@ func init() {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle --no-verify flag (#9 Auto Backup Verification)
|
||||
if c.Flags().Changed("no-verify") {
|
||||
noVerify, _ := c.Flags().GetBool("no-verify")
|
||||
cfg.VerifyAfterBackup = !noVerify
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
463
cmd/catalog_export.go
Normal file
463
cmd/catalog_export.go
Normal file
@ -0,0 +1,463 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"dbbackup/internal/catalog"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
exportOutput string
|
||||
exportFormat string
|
||||
)
|
||||
|
||||
// catalogExportCmd exports catalog to various formats
|
||||
var catalogExportCmd = &cobra.Command{
|
||||
Use: "export",
|
||||
Short: "Export catalog to file (CSV/HTML/JSON)",
|
||||
Long: `Export backup catalog to various formats for analysis, reporting, or archival.
|
||||
|
||||
Supports:
|
||||
- CSV format for spreadsheet import (Excel, LibreOffice)
|
||||
- HTML format for web-based reports and documentation
|
||||
- JSON format for programmatic access and integration
|
||||
|
||||
Examples:
|
||||
# Export to CSV
|
||||
dbbackup catalog export --format csv --output backups.csv
|
||||
|
||||
# Export to HTML report
|
||||
dbbackup catalog export --format html --output report.html
|
||||
|
||||
# Export specific database
|
||||
dbbackup catalog export --format csv --database myapp --output myapp_backups.csv
|
||||
|
||||
# Export date range
|
||||
dbbackup catalog export --format html --after 2026-01-01 --output january_report.html`,
|
||||
RunE: runCatalogExport,
|
||||
}
|
||||
|
||||
func init() {
|
||||
catalogCmd.AddCommand(catalogExportCmd)
|
||||
catalogExportCmd.Flags().StringVarP(&exportOutput, "output", "o", "", "Output file path (required)")
|
||||
catalogExportCmd.Flags().StringVarP(&exportFormat, "format", "f", "csv", "Export format: csv, html, json")
|
||||
catalogExportCmd.Flags().StringVar(&catalogDatabase, "database", "", "Filter by database name")
|
||||
catalogExportCmd.Flags().StringVar(&catalogStartDate, "after", "", "Show backups after date (YYYY-MM-DD)")
|
||||
catalogExportCmd.Flags().StringVar(&catalogEndDate, "before", "", "Show backups before date (YYYY-MM-DD)")
|
||||
catalogExportCmd.MarkFlagRequired("output")
|
||||
}
|
||||
|
||||
func runCatalogExport(cmd *cobra.Command, args []string) error {
|
||||
if exportOutput == "" {
|
||||
return fmt.Errorf("--output flag required")
|
||||
}
|
||||
|
||||
// Validate format
|
||||
exportFormat = strings.ToLower(exportFormat)
|
||||
if exportFormat != "csv" && exportFormat != "html" && exportFormat != "json" {
|
||||
return fmt.Errorf("invalid format: %s (supported: csv, html, json)", exportFormat)
|
||||
}
|
||||
|
||||
cat, err := openCatalog()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cat.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Build query
|
||||
query := &catalog.SearchQuery{
|
||||
Database: catalogDatabase,
|
||||
Limit: 0, // No limit - export all
|
||||
OrderBy: "created_at",
|
||||
OrderDesc: false, // Chronological order for exports
|
||||
}
|
||||
|
||||
// Parse dates if provided
|
||||
if catalogStartDate != "" {
|
||||
after, err := time.Parse("2006-01-02", catalogStartDate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid --after date format (use YYYY-MM-DD): %w", err)
|
||||
}
|
||||
query.StartDate = &after
|
||||
}
|
||||
|
||||
if catalogEndDate != "" {
|
||||
before, err := time.Parse("2006-01-02", catalogEndDate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid --before date format (use YYYY-MM-DD): %w", err)
|
||||
}
|
||||
query.EndDate = &before
|
||||
}
|
||||
|
||||
// Search backups
|
||||
entries, err := cat.Search(ctx, query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to search catalog: %w", err)
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
fmt.Println("No backups found matching criteria")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Export based on format
|
||||
switch exportFormat {
|
||||
case "csv":
|
||||
return exportCSV(entries, exportOutput)
|
||||
case "html":
|
||||
return exportHTML(entries, exportOutput, catalogDatabase)
|
||||
case "json":
|
||||
return exportJSON(entries, exportOutput)
|
||||
default:
|
||||
return fmt.Errorf("unsupported format: %s", exportFormat)
|
||||
}
|
||||
}
|
||||
|
||||
// exportCSV exports entries to CSV format
|
||||
func exportCSV(entries []*catalog.Entry, outputPath string) error {
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer := csv.NewWriter(file)
|
||||
defer writer.Flush()
|
||||
|
||||
// Header
|
||||
header := []string{
|
||||
"ID",
|
||||
"Database",
|
||||
"DatabaseType",
|
||||
"Host",
|
||||
"Port",
|
||||
"BackupPath",
|
||||
"BackupType",
|
||||
"SizeBytes",
|
||||
"SizeHuman",
|
||||
"SHA256",
|
||||
"Compression",
|
||||
"Encrypted",
|
||||
"CreatedAt",
|
||||
"DurationSeconds",
|
||||
"Status",
|
||||
"VerifiedAt",
|
||||
"VerifyValid",
|
||||
"TestedAt",
|
||||
"TestSuccess",
|
||||
"RetentionPolicy",
|
||||
}
|
||||
|
||||
if err := writer.Write(header); err != nil {
|
||||
return fmt.Errorf("failed to write CSV header: %w", err)
|
||||
}
|
||||
|
||||
// Data rows
|
||||
for _, entry := range entries {
|
||||
row := []string{
|
||||
fmt.Sprintf("%d", entry.ID),
|
||||
entry.Database,
|
||||
entry.DatabaseType,
|
||||
entry.Host,
|
||||
fmt.Sprintf("%d", entry.Port),
|
||||
entry.BackupPath,
|
||||
entry.BackupType,
|
||||
fmt.Sprintf("%d", entry.SizeBytes),
|
||||
catalog.FormatSize(entry.SizeBytes),
|
||||
entry.SHA256,
|
||||
entry.Compression,
|
||||
fmt.Sprintf("%t", entry.Encrypted),
|
||||
entry.CreatedAt.Format(time.RFC3339),
|
||||
fmt.Sprintf("%.2f", entry.Duration),
|
||||
string(entry.Status),
|
||||
formatTime(entry.VerifiedAt),
|
||||
formatBool(entry.VerifyValid),
|
||||
formatTime(entry.DrillTestedAt),
|
||||
formatBool(entry.DrillSuccess),
|
||||
entry.RetentionPolicy,
|
||||
}
|
||||
|
||||
if err := writer.Write(row); err != nil {
|
||||
return fmt.Errorf("failed to write CSV row: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Exported %d backups to CSV: %s\n", len(entries), outputPath)
|
||||
fmt.Printf(" Open with Excel, LibreOffice, or other spreadsheet software\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// exportHTML exports entries to HTML format with styling
|
||||
func exportHTML(entries []*catalog.Entry, outputPath string, database string) error {
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
title := "Backup Catalog Report"
|
||||
if database != "" {
|
||||
title = fmt.Sprintf("Backup Catalog Report: %s", database)
|
||||
}
|
||||
|
||||
// Write HTML header with embedded CSS
|
||||
htmlHeader := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>%s</title>
|
||||
<style>
|
||||
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 20px; background: #f5f5f5; }
|
||||
.container { max-width: 1400px; margin: 0 auto; background: white; padding: 30px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
|
||||
h1 { color: #2c3e50; border-bottom: 3px solid #3498db; padding-bottom: 10px; }
|
||||
.summary { background: #ecf0f1; padding: 15px; margin: 20px 0; border-radius: 5px; }
|
||||
.summary-item { display: inline-block; margin-right: 30px; }
|
||||
.summary-label { font-weight: bold; color: #7f8c8d; }
|
||||
.summary-value { color: #2c3e50; font-size: 18px; }
|
||||
table { width: 100%%; border-collapse: collapse; margin-top: 20px; }
|
||||
th { background: #34495e; color: white; padding: 12px; text-align: left; font-weight: 600; }
|
||||
td { padding: 10px; border-bottom: 1px solid #ecf0f1; }
|
||||
tr:hover { background: #f8f9fa; }
|
||||
.status-success { color: #27ae60; font-weight: bold; }
|
||||
.status-fail { color: #e74c3c; font-weight: bold; }
|
||||
.badge { padding: 3px 8px; border-radius: 3px; font-size: 12px; font-weight: bold; }
|
||||
.badge-encrypted { background: #3498db; color: white; }
|
||||
.badge-verified { background: #27ae60; color: white; }
|
||||
.badge-tested { background: #9b59b6; color: white; }
|
||||
.footer { margin-top: 30px; text-align: center; color: #95a5a6; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>%s</h1>
|
||||
`, title, title)
|
||||
|
||||
file.WriteString(htmlHeader)
|
||||
|
||||
// Summary section
|
||||
totalSize := int64(0)
|
||||
encryptedCount := 0
|
||||
verifiedCount := 0
|
||||
testedCount := 0
|
||||
|
||||
for _, entry := range entries {
|
||||
totalSize += entry.SizeBytes
|
||||
if entry.Encrypted {
|
||||
encryptedCount++
|
||||
}
|
||||
if entry.VerifyValid != nil && *entry.VerifyValid {
|
||||
verifiedCount++
|
||||
}
|
||||
if entry.DrillSuccess != nil && *entry.DrillSuccess {
|
||||
testedCount++
|
||||
}
|
||||
}
|
||||
|
||||
var oldestBackup, newestBackup time.Time
|
||||
if len(entries) > 0 {
|
||||
oldestBackup = entries[0].CreatedAt
|
||||
newestBackup = entries[len(entries)-1].CreatedAt
|
||||
}
|
||||
|
||||
summaryHTML := fmt.Sprintf(`
|
||||
<div class="summary">
|
||||
<div class="summary-item">
|
||||
<div class="summary-label">Total Backups:</div>
|
||||
<div class="summary-value">%d</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-label">Total Size:</div>
|
||||
<div class="summary-value">%s</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-label">Encrypted:</div>
|
||||
<div class="summary-value">%d (%.1f%%)</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-label">Verified:</div>
|
||||
<div class="summary-value">%d (%.1f%%)</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-label">DR Tested:</div>
|
||||
<div class="summary-value">%d (%.1f%%)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary">
|
||||
<div class="summary-item">
|
||||
<div class="summary-label">Oldest Backup:</div>
|
||||
<div class="summary-value">%s</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-label">Newest Backup:</div>
|
||||
<div class="summary-value">%s</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-label">Time Span:</div>
|
||||
<div class="summary-value">%s</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
len(entries),
|
||||
catalog.FormatSize(totalSize),
|
||||
encryptedCount, float64(encryptedCount)/float64(len(entries))*100,
|
||||
verifiedCount, float64(verifiedCount)/float64(len(entries))*100,
|
||||
testedCount, float64(testedCount)/float64(len(entries))*100,
|
||||
oldestBackup.Format("2006-01-02 15:04"),
|
||||
newestBackup.Format("2006-01-02 15:04"),
|
||||
formatTimeSpan(newestBackup.Sub(oldestBackup)),
|
||||
)
|
||||
|
||||
file.WriteString(summaryHTML)
|
||||
|
||||
// Table header
|
||||
tableHeader := `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Database</th>
|
||||
<th>Created</th>
|
||||
<th>Size</th>
|
||||
<th>Type</th>
|
||||
<th>Duration</th>
|
||||
<th>Status</th>
|
||||
<th>Attributes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`
|
||||
file.WriteString(tableHeader)
|
||||
|
||||
// Table rows
|
||||
for _, entry := range entries {
|
||||
badges := []string{}
|
||||
if entry.Encrypted {
|
||||
badges = append(badges, `<span class="badge badge-encrypted">Encrypted</span>`)
|
||||
}
|
||||
if entry.VerifyValid != nil && *entry.VerifyValid {
|
||||
badges = append(badges, `<span class="badge badge-verified">Verified</span>`)
|
||||
}
|
||||
if entry.DrillSuccess != nil && *entry.DrillSuccess {
|
||||
badges = append(badges, `<span class="badge badge-tested">DR Tested</span>`)
|
||||
}
|
||||
|
||||
statusClass := "status-success"
|
||||
statusText := string(entry.Status)
|
||||
if entry.Status == catalog.StatusFailed {
|
||||
statusClass = "status-fail"
|
||||
}
|
||||
|
||||
row := fmt.Sprintf(`
|
||||
<tr>
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
<td>%.1fs</td>
|
||||
<td class="%s">%s</td>
|
||||
<td>%s</td>
|
||||
</tr>`,
|
||||
html.EscapeString(entry.Database),
|
||||
entry.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
catalog.FormatSize(entry.SizeBytes),
|
||||
html.EscapeString(entry.BackupType),
|
||||
entry.Duration,
|
||||
statusClass,
|
||||
html.EscapeString(statusText),
|
||||
strings.Join(badges, " "),
|
||||
)
|
||||
file.WriteString(row)
|
||||
}
|
||||
|
||||
// Table footer and close HTML
|
||||
htmlFooter := `
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="footer">
|
||||
Generated by dbbackup on ` + time.Now().Format("2006-01-02 15:04:05") + `
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
file.WriteString(htmlFooter)
|
||||
|
||||
fmt.Printf("✅ Exported %d backups to HTML: %s\n", len(entries), outputPath)
|
||||
fmt.Printf(" Open in browser: file://%s\n", filepath.Join(os.Getenv("PWD"), exportOutput))
|
||||
return nil
|
||||
}
|
||||
|
||||
// exportJSON exports entries to JSON format
|
||||
func exportJSON(entries []*catalog.Entry, outputPath string) error {
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
encoder := json.NewEncoder(file)
|
||||
encoder.SetIndent("", " ")
|
||||
|
||||
if err := encoder.Encode(entries); err != nil {
|
||||
return fmt.Errorf("failed to encode JSON: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Exported %d backups to JSON: %s\n", len(entries), outputPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatTime formats *time.Time to string
|
||||
func formatTime(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// formatBool formats *bool to string
|
||||
func formatBool(b *bool) string {
|
||||
if b == nil {
|
||||
return ""
|
||||
}
|
||||
if *b {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
|
||||
// formatExportDuration formats *time.Duration to string
|
||||
func formatExportDuration(d *time.Duration) string {
|
||||
if d == nil {
|
||||
return ""
|
||||
}
|
||||
return d.String()
|
||||
}
|
||||
|
||||
// formatTimeSpan formats a duration in human-readable form
|
||||
func formatTimeSpan(d time.Duration) string {
|
||||
days := int(d.Hours() / 24)
|
||||
if days > 365 {
|
||||
years := days / 365
|
||||
return fmt.Sprintf("%d years", years)
|
||||
}
|
||||
if days > 30 {
|
||||
months := days / 30
|
||||
return fmt.Sprintf("%d months", months)
|
||||
}
|
||||
if days > 0 {
|
||||
return fmt.Sprintf("%d days", days)
|
||||
}
|
||||
return fmt.Sprintf("%.0f hours", d.Hours())
|
||||
}
|
||||
80
cmd/completion.go
Normal file
80
cmd/completion.go
Normal file
@ -0,0 +1,80 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var completionCmd = &cobra.Command{
|
||||
Use: "completion [bash|zsh|fish|powershell]",
|
||||
Short: "Generate shell completion scripts",
|
||||
Long: `Generate shell completion scripts for dbbackup commands.
|
||||
|
||||
The completion script allows tab-completion of:
|
||||
- Commands and subcommands
|
||||
- Flags and their values
|
||||
- File paths for backup/restore operations
|
||||
|
||||
Installation Instructions:
|
||||
|
||||
Bash:
|
||||
# Add to ~/.bashrc or ~/.bash_profile:
|
||||
source <(dbbackup completion bash)
|
||||
|
||||
# Or save to file and source it:
|
||||
dbbackup completion bash > ~/.dbbackup-completion.bash
|
||||
echo 'source ~/.dbbackup-completion.bash' >> ~/.bashrc
|
||||
|
||||
Zsh:
|
||||
# Add to ~/.zshrc:
|
||||
source <(dbbackup completion zsh)
|
||||
|
||||
# Or save to completion directory:
|
||||
dbbackup completion zsh > "${fpath[1]}/_dbbackup"
|
||||
|
||||
# For custom location:
|
||||
dbbackup completion zsh > ~/.dbbackup-completion.zsh
|
||||
echo 'source ~/.dbbackup-completion.zsh' >> ~/.zshrc
|
||||
|
||||
Fish:
|
||||
# Save to fish completion directory:
|
||||
dbbackup completion fish > ~/.config/fish/completions/dbbackup.fish
|
||||
|
||||
PowerShell:
|
||||
# Add to your PowerShell profile:
|
||||
dbbackup completion powershell | Out-String | Invoke-Expression
|
||||
|
||||
# Or save to profile:
|
||||
dbbackup completion powershell >> $PROFILE
|
||||
|
||||
After installation, restart your shell or source the completion file.
|
||||
|
||||
Note: Some flags may have conflicting shorthand letters across different
|
||||
subcommands (e.g., -d for both db-type and database). Tab completion will
|
||||
work correctly for the command you're using.`,
|
||||
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableFlagParsing: true, // Don't parse flags for completion generation
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
shell := args[0]
|
||||
|
||||
// Get root command without triggering flag merging
|
||||
root := cmd.Root()
|
||||
|
||||
switch shell {
|
||||
case "bash":
|
||||
root.GenBashCompletionV2(os.Stdout, true)
|
||||
case "zsh":
|
||||
root.GenZshCompletion(os.Stdout)
|
||||
case "fish":
|
||||
root.GenFishCompletion(os.Stdout, true)
|
||||
case "powershell":
|
||||
root.GenPowerShellCompletionWithDesc(os.Stdout)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(completionCmd)
|
||||
}
|
||||
212
cmd/estimate.go
Normal file
212
cmd/estimate.go
Normal file
@ -0,0 +1,212 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"dbbackup/internal/backup"
|
||||
)
|
||||
|
||||
var (
|
||||
estimateDetailed bool
|
||||
estimateJSON bool
|
||||
)
|
||||
|
||||
var estimateCmd = &cobra.Command{
|
||||
Use: "estimate",
|
||||
Short: "Estimate backup size and duration before running",
|
||||
Long: `Estimate how much disk space and time a backup will require.
|
||||
|
||||
This helps plan backup operations and ensure sufficient resources are available.
|
||||
The estimation queries database statistics without performing actual backups.
|
||||
|
||||
Examples:
|
||||
# Estimate single database backup
|
||||
dbbackup estimate single mydb
|
||||
|
||||
# Estimate full cluster backup
|
||||
dbbackup estimate cluster
|
||||
|
||||
# Detailed estimation with per-database breakdown
|
||||
dbbackup estimate cluster --detailed
|
||||
|
||||
# JSON output for automation
|
||||
dbbackup estimate single mydb --json`,
|
||||
}
|
||||
|
||||
var estimateSingleCmd = &cobra.Command{
|
||||
Use: "single [database]",
|
||||
Short: "Estimate single database backup size",
|
||||
Long: `Estimate the size and duration for backing up a single database.
|
||||
|
||||
Provides:
|
||||
- Raw database size
|
||||
- Estimated compressed size
|
||||
- Estimated backup duration
|
||||
- Required disk space
|
||||
- Disk space availability check
|
||||
- Recommended backup profile`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runEstimateSingle,
|
||||
}
|
||||
|
||||
var estimateClusterCmd = &cobra.Command{
|
||||
Use: "cluster",
|
||||
Short: "Estimate full cluster backup size",
|
||||
Long: `Estimate the size and duration for backing up an entire database cluster.
|
||||
|
||||
Provides:
|
||||
- Total cluster size
|
||||
- Per-database breakdown (with --detailed)
|
||||
- Estimated total duration (accounting for parallelism)
|
||||
- Required disk space
|
||||
- Disk space availability check
|
||||
|
||||
Uses configured parallelism settings to estimate actual backup time.`,
|
||||
RunE: runEstimateCluster,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(estimateCmd)
|
||||
estimateCmd.AddCommand(estimateSingleCmd)
|
||||
estimateCmd.AddCommand(estimateClusterCmd)
|
||||
|
||||
// Flags for both subcommands
|
||||
estimateCmd.PersistentFlags().BoolVar(&estimateDetailed, "detailed", false, "Show detailed per-database breakdown")
|
||||
estimateCmd.PersistentFlags().BoolVar(&estimateJSON, "json", false, "Output as JSON")
|
||||
}
|
||||
|
||||
func runEstimateSingle(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
databaseName := args[0]
|
||||
|
||||
fmt.Printf("🔍 Estimating backup size for database: %s\n\n", databaseName)
|
||||
|
||||
estimate, err := backup.EstimateBackupSize(ctx, cfg, log, databaseName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("estimation failed: %w", err)
|
||||
}
|
||||
|
||||
if estimateJSON {
|
||||
// Output JSON
|
||||
fmt.Println(toJSON(estimate))
|
||||
} else {
|
||||
// Human-readable output
|
||||
fmt.Println(backup.FormatSizeEstimate(estimate))
|
||||
fmt.Printf("\n Estimation completed in %v\n", estimate.EstimationTime)
|
||||
|
||||
// Warning if insufficient space
|
||||
if !estimate.HasSufficientSpace {
|
||||
fmt.Println()
|
||||
fmt.Println("⚠️ WARNING: Insufficient disk space!")
|
||||
fmt.Printf(" Need %s more space to proceed safely.\n",
|
||||
formatBytes(estimate.RequiredDiskSpace-estimate.AvailableDiskSpace))
|
||||
fmt.Println()
|
||||
fmt.Println(" Recommended actions:")
|
||||
fmt.Println(" 1. Free up disk space: dbbackup cleanup /backups --retention-days 7")
|
||||
fmt.Println(" 2. Use a different backup directory: --backup-dir /other/location")
|
||||
fmt.Println(" 3. Increase disk capacity")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runEstimateCluster(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
fmt.Println("🔍 Estimating cluster backup size...")
|
||||
fmt.Println()
|
||||
|
||||
estimate, err := backup.EstimateClusterBackupSize(ctx, cfg, log)
|
||||
if err != nil {
|
||||
return fmt.Errorf("estimation failed: %w", err)
|
||||
}
|
||||
|
||||
if estimateJSON {
|
||||
// Output JSON
|
||||
fmt.Println(toJSON(estimate))
|
||||
} else {
|
||||
// Human-readable output
|
||||
fmt.Println(backup.FormatClusterSizeEstimate(estimate))
|
||||
|
||||
// Detailed per-database breakdown
|
||||
if estimateDetailed && len(estimate.DatabaseEstimates) > 0 {
|
||||
fmt.Println()
|
||||
fmt.Println("Per-Database Breakdown:")
|
||||
fmt.Println("════════════════════════════════════════════════════════════")
|
||||
|
||||
// Sort databases by size (largest first)
|
||||
type dbSize struct {
|
||||
name string
|
||||
size int64
|
||||
}
|
||||
var sorted []dbSize
|
||||
for name, est := range estimate.DatabaseEstimates {
|
||||
sorted = append(sorted, dbSize{name, est.EstimatedRawSize})
|
||||
}
|
||||
// Simple sort by size (descending)
|
||||
for i := 0; i < len(sorted)-1; i++ {
|
||||
for j := i + 1; j < len(sorted); j++ {
|
||||
if sorted[j].size > sorted[i].size {
|
||||
sorted[i], sorted[j] = sorted[j], sorted[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Display top 10 largest
|
||||
displayCount := len(sorted)
|
||||
if displayCount > 10 {
|
||||
displayCount = 10
|
||||
}
|
||||
|
||||
for i := 0; i < displayCount; i++ {
|
||||
name := sorted[i].name
|
||||
est := estimate.DatabaseEstimates[name]
|
||||
fmt.Printf("\n%d. %s\n", i+1, name)
|
||||
fmt.Printf(" Raw: %s | Compressed: %s | Duration: %v\n",
|
||||
formatBytes(est.EstimatedRawSize),
|
||||
formatBytes(est.EstimatedCompressed),
|
||||
est.EstimatedDuration.Round(time.Second))
|
||||
if est.LargestTable != "" {
|
||||
fmt.Printf(" Largest table: %s (%s)\n",
|
||||
est.LargestTable,
|
||||
formatBytes(est.LargestTableSize))
|
||||
}
|
||||
}
|
||||
|
||||
if len(sorted) > 10 {
|
||||
fmt.Printf("\n... and %d more databases\n", len(sorted)-10)
|
||||
}
|
||||
}
|
||||
|
||||
// Warning if insufficient space
|
||||
if !estimate.HasSufficientSpace {
|
||||
fmt.Println()
|
||||
fmt.Println("⚠️ WARNING: Insufficient disk space!")
|
||||
fmt.Printf(" Need %s more space to proceed safely.\n",
|
||||
formatBytes(estimate.RequiredDiskSpace-estimate.AvailableDiskSpace))
|
||||
fmt.Println()
|
||||
fmt.Println(" Recommended actions:")
|
||||
fmt.Println(" 1. Free up disk space: dbbackup cleanup /backups --retention-days 7")
|
||||
fmt.Println(" 2. Use a different backup directory: --backup-dir /other/location")
|
||||
fmt.Println(" 3. Increase disk capacity")
|
||||
fmt.Println(" 4. Back up databases individually to spread across time/space")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// toJSON converts any struct to JSON string (simple helper)
|
||||
func toJSON(v interface{}) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
182
cmd/man.go
Normal file
182
cmd/man.go
Normal file
@ -0,0 +1,182 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/cobra/doc"
|
||||
)
|
||||
|
||||
var (
|
||||
manOutputDir string
|
||||
)
|
||||
|
||||
var manCmd = &cobra.Command{
|
||||
Use: "man",
|
||||
Short: "Generate man pages for dbbackup",
|
||||
Long: `Generate Unix manual (man) pages for all dbbackup commands.
|
||||
|
||||
Man pages are generated in standard groff format and can be viewed
|
||||
with the 'man' command or installed system-wide.
|
||||
|
||||
Installation:
|
||||
# Generate pages
|
||||
dbbackup man --output /tmp/man
|
||||
|
||||
# Install system-wide (requires root)
|
||||
sudo cp /tmp/man/*.1 /usr/local/share/man/man1/
|
||||
sudo mandb # Update man database
|
||||
|
||||
# View pages
|
||||
man dbbackup
|
||||
man dbbackup-backup
|
||||
man dbbackup-restore
|
||||
|
||||
Examples:
|
||||
# Generate to current directory
|
||||
dbbackup man
|
||||
|
||||
# Generate to specific directory
|
||||
dbbackup man --output ./docs/man
|
||||
|
||||
# Generate and install system-wide
|
||||
dbbackup man --output /tmp/man && \
|
||||
sudo cp /tmp/man/*.1 /usr/local/share/man/man1/ && \
|
||||
sudo mandb`,
|
||||
DisableFlagParsing: true, // Avoid shorthand conflicts during generation
|
||||
RunE: runGenerateMan,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(manCmd)
|
||||
manCmd.Flags().StringVarP(&manOutputDir, "output", "o", "./man", "Output directory for man pages")
|
||||
|
||||
// Parse flags manually since DisableFlagParsing is enabled
|
||||
manCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||
cmd.Parent().HelpFunc()(cmd, args)
|
||||
})
|
||||
}
|
||||
|
||||
func runGenerateMan(cmd *cobra.Command, args []string) error {
|
||||
// Parse flags manually since DisableFlagParsing is enabled
|
||||
outputDir := "./man"
|
||||
for i := 0; i < len(args); i++ {
|
||||
if args[i] == "--output" || args[i] == "-o" {
|
||||
if i+1 < len(args) {
|
||||
outputDir = args[i+1]
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create output directory
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
|
||||
// Generate man pages for root and all subcommands
|
||||
header := &doc.GenManHeader{
|
||||
Title: "DBBACKUP",
|
||||
Section: "1",
|
||||
Source: "dbbackup",
|
||||
Manual: "Database Backup Tool",
|
||||
}
|
||||
|
||||
// Due to shorthand flag conflicts in some subcommands (-d for db-type vs database),
|
||||
// we generate man pages command-by-command, catching any errors
|
||||
root := cmd.Root()
|
||||
generatedCount := 0
|
||||
failedCount := 0
|
||||
|
||||
// Helper to generate man page for a single command
|
||||
genManForCommand := func(c *cobra.Command) {
|
||||
// Recover from panic due to flag conflicts
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
failedCount++
|
||||
// Silently skip commands with flag conflicts
|
||||
}
|
||||
}()
|
||||
|
||||
filename := filepath.Join(outputDir, c.CommandPath()+".1")
|
||||
// Replace spaces with hyphens for filename
|
||||
filename = filepath.Join(outputDir, filepath.Base(c.CommandPath())+".1")
|
||||
|
||||
f, err := os.Create(filename)
|
||||
if err != nil {
|
||||
failedCount++
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if err := doc.GenMan(c, header, f); err != nil {
|
||||
failedCount++
|
||||
os.Remove(filename) // Clean up partial file
|
||||
} else {
|
||||
generatedCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Generate for root command
|
||||
genManForCommand(root)
|
||||
|
||||
// Walk through all commands
|
||||
var walkCommands func(*cobra.Command)
|
||||
walkCommands = func(c *cobra.Command) {
|
||||
for _, sub := range c.Commands() {
|
||||
// Skip hidden commands
|
||||
if sub.Hidden {
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to generate man page
|
||||
genManForCommand(sub)
|
||||
|
||||
// Recurse into subcommands
|
||||
walkCommands(sub)
|
||||
}
|
||||
}
|
||||
|
||||
walkCommands(root)
|
||||
|
||||
fmt.Printf("✅ Generated %d man pages in %s", generatedCount, outputDir)
|
||||
if failedCount > 0 {
|
||||
fmt.Printf(" (%d skipped due to flag conflicts)\n", failedCount)
|
||||
} else {
|
||||
fmt.Println()
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
fmt.Println("📖 Installation Instructions:")
|
||||
fmt.Println()
|
||||
fmt.Println(" 1. Install system-wide (requires root):")
|
||||
fmt.Printf(" sudo cp %s/*.1 /usr/local/share/man/man1/\n", outputDir)
|
||||
fmt.Println(" sudo mandb")
|
||||
fmt.Println()
|
||||
fmt.Println(" 2. Test locally (no installation):")
|
||||
fmt.Printf(" man -l %s/dbbackup.1\n", outputDir)
|
||||
fmt.Println()
|
||||
fmt.Println(" 3. View installed pages:")
|
||||
fmt.Println(" man dbbackup")
|
||||
fmt.Println(" man dbbackup-backup")
|
||||
fmt.Println(" man dbbackup-restore")
|
||||
fmt.Println()
|
||||
|
||||
// Show some example pages
|
||||
files, err := filepath.Glob(filepath.Join(outputDir, "*.1"))
|
||||
if err == nil && len(files) > 0 {
|
||||
fmt.Println("📋 Generated Pages (sample):")
|
||||
for i, file := range files {
|
||||
if i >= 5 {
|
||||
fmt.Printf(" ... and %d more\n", len(files)-5)
|
||||
break
|
||||
}
|
||||
fmt.Printf(" - %s\n", filepath.Base(file))
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
58
cmd/pitr.go
58
cmd/pitr.go
@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@ -505,12 +506,24 @@ func runPITRStatus(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Show WAL archive statistics if archive directory can be determined
|
||||
if config.ArchiveCommand != "" {
|
||||
// Extract archive dir from command (simple parsing)
|
||||
fmt.Println()
|
||||
fmt.Println("WAL Archive Statistics:")
|
||||
fmt.Println("======================================================")
|
||||
// TODO: Parse archive dir and show stats
|
||||
fmt.Println(" (Use 'dbbackup wal list --archive-dir <dir>' to view archives)")
|
||||
archiveDir := extractArchiveDirFromCommand(config.ArchiveCommand)
|
||||
if archiveDir != "" {
|
||||
fmt.Println()
|
||||
fmt.Println("WAL Archive Statistics:")
|
||||
fmt.Println("======================================================")
|
||||
stats, err := wal.GetArchiveStats(archiveDir)
|
||||
if err != nil {
|
||||
fmt.Printf(" ⚠ Could not read archive: %v\n", err)
|
||||
fmt.Printf(" (Archive directory: %s)\n", archiveDir)
|
||||
} else {
|
||||
fmt.Print(wal.FormatArchiveStats(stats))
|
||||
}
|
||||
} else {
|
||||
fmt.Println()
|
||||
fmt.Println("WAL Archive Statistics:")
|
||||
fmt.Println("======================================================")
|
||||
fmt.Println(" (Use 'dbbackup wal list --archive-dir <dir>' to view archives)")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@ -1309,3 +1322,36 @@ func runMySQLPITREnable(cmd *cobra.Command, args []string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractArchiveDirFromCommand attempts to extract the archive directory
|
||||
// from a PostgreSQL archive_command string
|
||||
// Example: "dbbackup wal archive %p %f --archive-dir=/mnt/wal" → "/mnt/wal"
|
||||
func extractArchiveDirFromCommand(command string) string {
|
||||
// Look for common patterns:
|
||||
// 1. --archive-dir=/path
|
||||
// 2. --archive-dir /path
|
||||
// 3. Plain path argument
|
||||
|
||||
parts := strings.Fields(command)
|
||||
for i, part := range parts {
|
||||
// Pattern: --archive-dir=/path
|
||||
if strings.HasPrefix(part, "--archive-dir=") {
|
||||
return strings.TrimPrefix(part, "--archive-dir=")
|
||||
}
|
||||
// Pattern: --archive-dir /path
|
||||
if part == "--archive-dir" && i+1 < len(parts) {
|
||||
return parts[i+1]
|
||||
}
|
||||
}
|
||||
|
||||
// If command contains dbbackup, the last argument might be the archive dir
|
||||
if strings.Contains(command, "dbbackup") && len(parts) > 2 {
|
||||
lastArg := parts[len(parts)-1]
|
||||
// Check if it looks like a path
|
||||
if strings.HasPrefix(lastArg, "/") || strings.HasPrefix(lastArg, "./") {
|
||||
return lastArg
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
@ -163,7 +163,8 @@ func Execute(ctx context.Context, config *config.Config, logger logger.Logger) e
|
||||
rootCmd.PersistentFlags().StringVar(&cfg.Socket, "socket", cfg.Socket, "Unix socket path for MySQL/MariaDB (e.g., /var/run/mysqld/mysqld.sock)")
|
||||
rootCmd.PersistentFlags().StringVar(&cfg.User, "user", cfg.User, "Database user")
|
||||
rootCmd.PersistentFlags().StringVar(&cfg.Database, "database", cfg.Database, "Database name")
|
||||
rootCmd.PersistentFlags().StringVar(&cfg.Password, "password", cfg.Password, "Database password")
|
||||
// SECURITY: Password flag removed - use PGPASSWORD/MYSQL_PWD environment variable or .pgpass file
|
||||
// rootCmd.PersistentFlags().StringVar(&cfg.Password, "password", cfg.Password, "Database password")
|
||||
rootCmd.PersistentFlags().StringVarP(&cfg.DatabaseType, "db-type", "d", cfg.DatabaseType, "Database type (postgres|mysql|mariadb)")
|
||||
rootCmd.PersistentFlags().StringVar(&cfg.BackupDir, "backup-dir", cfg.BackupDir, "Backup directory")
|
||||
rootCmd.PersistentFlags().BoolVar(&cfg.NoColor, "no-color", cfg.NoColor, "Disable colored output")
|
||||
|
||||
168
cmd/version.go
Normal file
168
cmd/version.go
Normal file
@ -0,0 +1,168 @@
|
||||
// Package cmd - version command showing detailed build and system info
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var versionOutputFormat string
|
||||
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Show detailed version and system information",
|
||||
Long: `Display comprehensive version information including:
|
||||
|
||||
- dbbackup version, build time, and git commit
|
||||
- Go runtime version
|
||||
- Operating system and architecture
|
||||
- Installed database tool versions (pg_dump, mysqldump, etc.)
|
||||
- System information
|
||||
|
||||
Useful for troubleshooting and bug reports.
|
||||
|
||||
Examples:
|
||||
# Show version info
|
||||
dbbackup version
|
||||
|
||||
# JSON output for scripts
|
||||
dbbackup version --format json
|
||||
|
||||
# Short version only
|
||||
dbbackup version --format short`,
|
||||
Run: runVersionCmd,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
versionCmd.Flags().StringVar(&versionOutputFormat, "format", "table", "Output format (table, json, short)")
|
||||
}
|
||||
|
||||
type versionInfo struct {
|
||||
Version string `json:"version"`
|
||||
BuildTime string `json:"build_time"`
|
||||
GitCommit string `json:"git_commit"`
|
||||
GoVersion string `json:"go_version"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
NumCPU int `json:"num_cpu"`
|
||||
DatabaseTools map[string]string `json:"database_tools"`
|
||||
}
|
||||
|
||||
func runVersionCmd(cmd *cobra.Command, args []string) {
|
||||
info := collectVersionInfo()
|
||||
|
||||
switch versionOutputFormat {
|
||||
case "json":
|
||||
outputVersionJSON(info)
|
||||
case "short":
|
||||
fmt.Printf("dbbackup %s\n", info.Version)
|
||||
default:
|
||||
outputTable(info)
|
||||
}
|
||||
}
|
||||
|
||||
func collectVersionInfo() versionInfo {
|
||||
info := versionInfo{
|
||||
Version: cfg.Version,
|
||||
BuildTime: cfg.BuildTime,
|
||||
GitCommit: cfg.GitCommit,
|
||||
GoVersion: runtime.Version(),
|
||||
OS: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
NumCPU: runtime.NumCPU(),
|
||||
DatabaseTools: make(map[string]string),
|
||||
}
|
||||
|
||||
// Check database tools
|
||||
tools := []struct {
|
||||
name string
|
||||
command string
|
||||
args []string
|
||||
}{
|
||||
{"pg_dump", "pg_dump", []string{"--version"}},
|
||||
{"pg_restore", "pg_restore", []string{"--version"}},
|
||||
{"psql", "psql", []string{"--version"}},
|
||||
{"mysqldump", "mysqldump", []string{"--version"}},
|
||||
{"mysql", "mysql", []string{"--version"}},
|
||||
{"mariadb-dump", "mariadb-dump", []string{"--version"}},
|
||||
}
|
||||
|
||||
for _, tool := range tools {
|
||||
version := getToolVersion(tool.command, tool.args)
|
||||
if version != "" {
|
||||
info.DatabaseTools[tool.name] = version
|
||||
}
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
func getToolVersion(command string, args []string) string {
|
||||
cmd := exec.Command(command, args...)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Parse first line and extract version
|
||||
line := strings.Split(string(output), "\n")[0]
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
// Try to extract just the version number
|
||||
// e.g., "pg_dump (PostgreSQL) 16.1" -> "16.1"
|
||||
// e.g., "mysqldump Ver 8.0.35" -> "8.0.35"
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) > 0 {
|
||||
// Return last part which is usually the version
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
|
||||
return line
|
||||
}
|
||||
|
||||
func outputVersionJSON(info versionInfo) {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
enc.Encode(info)
|
||||
}
|
||||
|
||||
func outputTable(info versionInfo) {
|
||||
fmt.Println()
|
||||
fmt.Println("╔═══════════════════════════════════════════════════════════════╗")
|
||||
fmt.Println("║ dbbackup Version Info ║")
|
||||
fmt.Println("╠═══════════════════════════════════════════════════════════════╣")
|
||||
fmt.Printf("║ %-20s %-40s ║\n", "Version:", info.Version)
|
||||
fmt.Printf("║ %-20s %-40s ║\n", "Build Time:", info.BuildTime)
|
||||
|
||||
// Truncate commit if too long
|
||||
commit := info.GitCommit
|
||||
if len(commit) > 40 {
|
||||
commit = commit[:40]
|
||||
}
|
||||
fmt.Printf("║ %-20s %-40s ║\n", "Git Commit:", commit)
|
||||
fmt.Println("╠═══════════════════════════════════════════════════════════════╣")
|
||||
fmt.Printf("║ %-20s %-40s ║\n", "Go Version:", info.GoVersion)
|
||||
fmt.Printf("║ %-20s %-40s ║\n", "OS/Arch:", fmt.Sprintf("%s/%s", info.OS, info.Arch))
|
||||
fmt.Printf("║ %-20s %-40d ║\n", "CPU Cores:", info.NumCPU)
|
||||
fmt.Println("╠═══════════════════════════════════════════════════════════════╣")
|
||||
fmt.Println("║ Database Tools ║")
|
||||
fmt.Println("╟───────────────────────────────────────────────────────────────╢")
|
||||
|
||||
if len(info.DatabaseTools) == 0 {
|
||||
fmt.Println("║ (none detected) ║")
|
||||
} else {
|
||||
for tool, version := range info.DatabaseTools {
|
||||
fmt.Printf("║ %-18s %-41s ║\n", tool+":", version)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("╚═══════════════════════════════════════════════════════════════╝")
|
||||
fmt.Println()
|
||||
}
|
||||
5
go.mod
5
go.mod
@ -23,6 +23,7 @@ require (
|
||||
github.com/hashicorp/go-multierror v1.1.1
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
github.com/klauspost/pgzip v1.2.6
|
||||
github.com/mattn/go-isatty v0.0.20
|
||||
github.com/schollz/progressbar/v3 v3.19.0
|
||||
github.com/shirou/gopsutil/v3 v3.24.5
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
@ -69,6 +70,7 @@ require (
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
@ -90,7 +92,6 @@ require (
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
||||
@ -102,6 +103,7 @@ require (
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
@ -130,6 +132,7 @@ require (
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect
|
||||
google.golang.org/grpc v1.76.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
10
go.sum
10
go.sum
@ -106,6 +106,7 @@ github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7m
|
||||
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
|
||||
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
|
||||
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@ -177,6 +178,10 @@ github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+
|
||||
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
|
||||
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
@ -216,6 +221,9 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc=
|
||||
github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
|
||||
@ -312,6 +320,8 @@ google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94U
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
@ -10,6 +12,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@ -27,6 +30,9 @@ import (
|
||||
"dbbackup/internal/progress"
|
||||
"dbbackup/internal/security"
|
||||
"dbbackup/internal/swap"
|
||||
"dbbackup/internal/verification"
|
||||
|
||||
"github.com/klauspost/pgzip"
|
||||
)
|
||||
|
||||
// ProgressCallback is called with byte-level progress updates during backup operations
|
||||
@ -171,7 +177,8 @@ func (e *Engine) BackupSingle(ctx context.Context, databaseName string) error {
|
||||
}
|
||||
e.cfg.BackupDir = validBackupDir
|
||||
|
||||
if err := os.MkdirAll(e.cfg.BackupDir, 0755); err != nil {
|
||||
// Use SecureMkdirAll to handle race conditions and apply secure permissions
|
||||
if err := fs.SecureMkdirAll(e.cfg.BackupDir, 0700); err != nil {
|
||||
err = fmt.Errorf("failed to create backup directory %s. Check write permissions or use --backup-dir to specify writable location: %w", e.cfg.BackupDir, err)
|
||||
prepStep.Fail(err)
|
||||
tracker.Fail(err)
|
||||
@ -259,6 +266,26 @@ func (e *Engine) BackupSingle(ctx context.Context, databaseName string) error {
|
||||
metaStep.Complete("Metadata file created")
|
||||
}
|
||||
|
||||
// Auto-verify backup integrity if enabled (HIGH priority #9)
|
||||
if e.cfg.VerifyAfterBackup {
|
||||
verifyStep := tracker.AddStep("post-verify", "Verifying backup integrity")
|
||||
e.log.Info("Post-backup verification enabled, checking integrity...")
|
||||
|
||||
if result, err := verification.Verify(outputFile); err != nil {
|
||||
e.log.Error("Post-backup verification failed", "error", err)
|
||||
verifyStep.Fail(fmt.Errorf("verification failed: %w", err))
|
||||
tracker.Fail(fmt.Errorf("backup created but verification failed: %w", err))
|
||||
return fmt.Errorf("backup verification failed (backup may be corrupted): %w", err)
|
||||
} else if !result.Valid {
|
||||
verifyStep.Fail(fmt.Errorf("verification failed: %s", result.Error))
|
||||
tracker.Fail(fmt.Errorf("backup created but verification failed: %s", result.Error))
|
||||
return fmt.Errorf("backup verification failed: %s", result.Error)
|
||||
} else {
|
||||
verifyStep.Complete(fmt.Sprintf("Backup verified (SHA-256: %s...)", result.CalculatedSHA256[:16]))
|
||||
e.log.Info("Backup verification successful", "sha256", result.CalculatedSHA256)
|
||||
}
|
||||
}
|
||||
|
||||
// Record metrics for observability
|
||||
if info, err := os.Stat(outputFile); err == nil && metrics.GlobalMetrics != nil {
|
||||
metrics.GlobalMetrics.RecordOperation("backup_single", databaseName, time.Now().Add(-time.Minute), info.Size(), true, 0)
|
||||
@ -283,8 +310,8 @@ func (e *Engine) BackupSingle(ctx context.Context, databaseName string) error {
|
||||
func (e *Engine) BackupSample(ctx context.Context, databaseName string) error {
|
||||
operation := e.log.StartOperation("Sample Database Backup")
|
||||
|
||||
// Ensure backup directory exists
|
||||
if err := os.MkdirAll(e.cfg.BackupDir, 0755); err != nil {
|
||||
// Ensure backup directory exists with race condition handling
|
||||
if err := fs.SecureMkdirAll(e.cfg.BackupDir, 0755); err != nil {
|
||||
operation.Fail("Failed to create backup directory")
|
||||
return fmt.Errorf("failed to create backup directory: %w", err)
|
||||
}
|
||||
@ -367,8 +394,8 @@ func (e *Engine) BackupCluster(ctx context.Context) error {
|
||||
quietProgress.Start("Starting cluster backup (all databases)")
|
||||
}
|
||||
|
||||
// Ensure backup directory exists
|
||||
if err := os.MkdirAll(e.cfg.BackupDir, 0755); err != nil {
|
||||
// Ensure backup directory exists with race condition handling
|
||||
if err := fs.SecureMkdirAll(e.cfg.BackupDir, 0755); err != nil {
|
||||
operation.Fail("Failed to create backup directory")
|
||||
quietProgress.Fail("Failed to create backup directory")
|
||||
return fmt.Errorf("failed to create backup directory: %w", err)
|
||||
@ -402,8 +429,8 @@ func (e *Engine) BackupCluster(ctx context.Context) error {
|
||||
|
||||
operation.Update("Starting cluster backup")
|
||||
|
||||
// Create temporary directory
|
||||
if err := os.MkdirAll(filepath.Join(tempDir, "dumps"), 0755); err != nil {
|
||||
// Create temporary directory with secure permissions and race condition handling
|
||||
if err := fs.SecureMkdirAll(filepath.Join(tempDir, "dumps"), 0700); err != nil {
|
||||
operation.Fail("Failed to create temporary directory")
|
||||
quietProgress.Fail("Failed to create temporary directory")
|
||||
return fmt.Errorf("failed to create temp directory: %w", err)
|
||||
@ -595,6 +622,24 @@ func (e *Engine) BackupCluster(ctx context.Context) error {
|
||||
e.log.Warn("Failed to create cluster metadata file", "error", err)
|
||||
}
|
||||
|
||||
// Auto-verify cluster backup integrity if enabled (HIGH priority #9)
|
||||
if e.cfg.VerifyAfterBackup {
|
||||
e.printf(" Verifying cluster backup integrity...\n")
|
||||
e.log.Info("Post-backup verification enabled, checking cluster archive...")
|
||||
|
||||
// For cluster backups (tar.gz), we do a quick extraction test
|
||||
// Full SHA-256 verification would require decompressing entire archive
|
||||
if err := e.verifyClusterArchive(ctx, outputFile); err != nil {
|
||||
e.log.Error("Cluster backup verification failed", "error", err)
|
||||
quietProgress.Fail(fmt.Sprintf("Cluster backup created but verification failed: %v", err))
|
||||
operation.Fail("Cluster backup verification failed")
|
||||
return fmt.Errorf("cluster backup verification failed: %w", err)
|
||||
} else {
|
||||
e.printf(" [OK] Cluster backup verified successfully\n")
|
||||
e.log.Info("Cluster backup verification successful", "archive", outputFile)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -716,8 +761,8 @@ func (e *Engine) executeMySQLWithProgressAndCompression(ctx context.Context, cmd
|
||||
dumpCmd.Env = append(dumpCmd.Env, "MYSQL_PWD="+e.cfg.Password)
|
||||
}
|
||||
|
||||
// Create output file
|
||||
outFile, err := os.Create(outputFile)
|
||||
// Create output file with secure permissions (0600)
|
||||
outFile, err := fs.SecureCreate(outputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
@ -757,7 +802,7 @@ func (e *Engine) executeMySQLWithProgressAndCompression(ctx context.Context, cmd
|
||||
// Copy mysqldump output through pgzip in a goroutine
|
||||
copyDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := io.Copy(gzWriter, pipe)
|
||||
_, err := fs.CopyWithContext(ctx, gzWriter, pipe)
|
||||
copyDone <- err
|
||||
}()
|
||||
|
||||
@ -808,8 +853,8 @@ func (e *Engine) executeMySQLWithCompression(ctx context.Context, cmdArgs []stri
|
||||
dumpCmd.Env = append(dumpCmd.Env, "MYSQL_PWD="+e.cfg.Password)
|
||||
}
|
||||
|
||||
// Create output file
|
||||
outFile, err := os.Create(outputFile)
|
||||
// Create output file with secure permissions (0600)
|
||||
outFile, err := fs.SecureCreate(outputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
@ -836,7 +881,7 @@ func (e *Engine) executeMySQLWithCompression(ctx context.Context, cmdArgs []stri
|
||||
// Copy mysqldump output through pgzip in a goroutine
|
||||
copyDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := io.Copy(gzWriter, pipe)
|
||||
_, err := fs.CopyWithContext(ctx, gzWriter, pipe)
|
||||
copyDone <- err
|
||||
}()
|
||||
|
||||
@ -1202,6 +1247,65 @@ func (e *Engine) createClusterMetadata(backupFile string, databases []string, su
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyClusterArchive performs quick integrity check on cluster backup archive
|
||||
func (e *Engine) verifyClusterArchive(ctx context.Context, archivePath string) error {
|
||||
// Check file exists and is readable
|
||||
file, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot open archive: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Get file size
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot stat archive: %w", err)
|
||||
}
|
||||
|
||||
// Basic sanity checks
|
||||
if info.Size() == 0 {
|
||||
return fmt.Errorf("archive is empty (0 bytes)")
|
||||
}
|
||||
|
||||
if info.Size() < 100 {
|
||||
return fmt.Errorf("archive suspiciously small (%d bytes)", info.Size())
|
||||
}
|
||||
|
||||
// Verify tar.gz structure by reading header
|
||||
gzipReader, err := gzip.NewReader(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid gzip format: %w", err)
|
||||
}
|
||||
defer gzipReader.Close()
|
||||
|
||||
// Read tar header to verify archive structure
|
||||
tarReader := tar.NewReader(gzipReader)
|
||||
fileCount := 0
|
||||
for {
|
||||
_, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break // End of archive
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("corrupted tar archive at entry %d: %w", fileCount, err)
|
||||
}
|
||||
fileCount++
|
||||
|
||||
// Limit scan to first 100 entries for performance
|
||||
// (cluster backup should have globals + N database dumps)
|
||||
if fileCount >= 100 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if fileCount == 0 {
|
||||
return fmt.Errorf("archive contains no files")
|
||||
}
|
||||
|
||||
e.log.Debug("Cluster archive verification passed", "files_checked", fileCount, "size_bytes", info.Size())
|
||||
return nil
|
||||
}
|
||||
|
||||
// uploadToCloud uploads a backup file to cloud storage
|
||||
func (e *Engine) uploadToCloud(ctx context.Context, backupFile string, tracker *progress.OperationTracker) error {
|
||||
uploadStep := tracker.AddStep("cloud_upload", "Uploading to cloud storage")
|
||||
@ -1414,10 +1518,10 @@ func (e *Engine) executeCommand(ctx context.Context, cmdArgs []string, outputFil
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeWithStreamingCompression handles plain format dumps with external compression
|
||||
// Uses: pg_dump | pigz > file.sql.gz (zero-copy streaming)
|
||||
// executeWithStreamingCompression handles plain format dumps with in-process pgzip compression
|
||||
// Uses: pg_dump stdout → pgzip.Writer → file.sql.gz (no external process)
|
||||
func (e *Engine) executeWithStreamingCompression(ctx context.Context, cmdArgs []string, outputFile string) error {
|
||||
e.log.Debug("Using streaming compression for large database")
|
||||
e.log.Debug("Using in-process pgzip compression for large database")
|
||||
|
||||
// Derive compressed output filename. If the output was named *.dump we replace that
|
||||
// with *.sql.gz; otherwise append .gz to the provided output file so we don't
|
||||
@ -1439,44 +1543,17 @@ func (e *Engine) executeWithStreamingCompression(ctx context.Context, cmdArgs []
|
||||
dumpCmd.Env = append(dumpCmd.Env, "PGPASSWORD="+e.cfg.Password)
|
||||
}
|
||||
|
||||
// Check for pigz (parallel gzip)
|
||||
compressor := "gzip"
|
||||
compressorArgs := []string{"-c"}
|
||||
|
||||
if _, err := exec.LookPath("pigz"); err == nil {
|
||||
compressor = "pigz"
|
||||
compressorArgs = []string{"-p", strconv.Itoa(e.cfg.Jobs), "-c"}
|
||||
e.log.Debug("Using pigz for parallel compression", "threads", e.cfg.Jobs)
|
||||
}
|
||||
|
||||
// Create compression command
|
||||
compressCmd := exec.CommandContext(ctx, compressor, compressorArgs...)
|
||||
|
||||
// Create output file
|
||||
outFile, err := os.Create(compressedFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
// Set up pipeline: pg_dump | pigz > file.sql.gz
|
||||
// Get stdout pipe from pg_dump
|
||||
dumpStdout, err := dumpCmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create dump stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
compressCmd.Stdin = dumpStdout
|
||||
compressCmd.Stdout = outFile
|
||||
|
||||
// Capture stderr from both commands
|
||||
// Capture stderr from pg_dump
|
||||
dumpStderr, err := dumpCmd.StderrPipe()
|
||||
if err != nil {
|
||||
e.log.Warn("Failed to capture dump stderr", "error", err)
|
||||
}
|
||||
compressStderr, err := compressCmd.StderrPipe()
|
||||
if err != nil {
|
||||
e.log.Warn("Failed to capture compress stderr", "error", err)
|
||||
}
|
||||
|
||||
// Stream stderr output
|
||||
if dumpStderr != nil {
|
||||
@ -1491,31 +1568,41 @@ func (e *Engine) executeWithStreamingCompression(ctx context.Context, cmdArgs []
|
||||
}()
|
||||
}
|
||||
|
||||
if compressStderr != nil {
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(compressStderr)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line != "" {
|
||||
e.log.Debug("compression", "output", line)
|
||||
}
|
||||
}
|
||||
}()
|
||||
// Create output file with secure permissions (0600)
|
||||
outFile, err := fs.SecureCreate(compressedFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
// Start compression first
|
||||
if err := compressCmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start compressor: %w", err)
|
||||
// Create pgzip writer with parallel compression
|
||||
// Use configured Jobs or default to NumCPU
|
||||
workers := e.cfg.Jobs
|
||||
if workers <= 0 {
|
||||
workers = runtime.NumCPU()
|
||||
}
|
||||
gzWriter, err := pgzip.NewWriterLevel(outFile, pgzip.BestSpeed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create pgzip writer: %w", err)
|
||||
}
|
||||
if err := gzWriter.SetConcurrency(256*1024, workers); err != nil {
|
||||
e.log.Warn("Failed to set pgzip concurrency", "error", err)
|
||||
}
|
||||
e.log.Debug("Using pgzip for parallel compression", "workers", workers)
|
||||
|
||||
// Then start pg_dump
|
||||
// Start pg_dump
|
||||
if err := dumpCmd.Start(); err != nil {
|
||||
compressCmd.Process.Kill()
|
||||
return fmt.Errorf("failed to start pg_dump: %w", err)
|
||||
}
|
||||
|
||||
// Copy from pg_dump stdout to pgzip writer in a goroutine
|
||||
copyDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, copyErr := fs.CopyWithContext(ctx, gzWriter, dumpStdout)
|
||||
copyDone <- copyErr
|
||||
}()
|
||||
|
||||
// Wait for pg_dump in a goroutine to handle context timeout properly
|
||||
// This prevents deadlock if pipe buffer fills and pg_dump blocks
|
||||
dumpDone := make(chan error, 1)
|
||||
go func() {
|
||||
dumpDone <- dumpCmd.Wait()
|
||||
@ -1533,33 +1620,29 @@ func (e *Engine) executeWithStreamingCompression(ctx context.Context, cmdArgs []
|
||||
dumpErr = ctx.Err()
|
||||
}
|
||||
|
||||
// Close stdout pipe to signal compressor we're done
|
||||
// This MUST happen after pg_dump exits to avoid broken pipe
|
||||
dumpStdout.Close()
|
||||
// Wait for copy to complete
|
||||
copyErr := <-copyDone
|
||||
|
||||
// Wait for compression to complete
|
||||
compressErr := compressCmd.Wait()
|
||||
// Close gzip writer to flush remaining data
|
||||
gzCloseErr := gzWriter.Close()
|
||||
|
||||
// Check errors - compressor failure first (it's usually the root cause)
|
||||
if compressErr != nil {
|
||||
e.log.Error("Compressor failed", "error", compressErr)
|
||||
return fmt.Errorf("compression failed (check disk space): %w", compressErr)
|
||||
}
|
||||
// Check errors in order of priority
|
||||
if dumpErr != nil {
|
||||
// Check for SIGPIPE (exit code 141) - indicates compressor died first
|
||||
if exitErr, ok := dumpErr.(*exec.ExitError); ok && exitErr.ExitCode() == 141 {
|
||||
e.log.Error("pg_dump received SIGPIPE - compressor may have failed")
|
||||
return fmt.Errorf("pg_dump broken pipe - check disk space and compressor")
|
||||
}
|
||||
return fmt.Errorf("pg_dump failed: %w", dumpErr)
|
||||
}
|
||||
if copyErr != nil {
|
||||
return fmt.Errorf("compression copy failed: %w", copyErr)
|
||||
}
|
||||
if gzCloseErr != nil {
|
||||
return fmt.Errorf("compression flush failed: %w", gzCloseErr)
|
||||
}
|
||||
|
||||
// Sync file to disk to ensure durability (prevents truncation on power loss)
|
||||
if err := outFile.Sync(); err != nil {
|
||||
e.log.Warn("Failed to sync output file", "error", err)
|
||||
}
|
||||
|
||||
e.log.Debug("Streaming compression completed", "output", compressedFile)
|
||||
e.log.Debug("In-process pgzip compression completed", "output", compressedFile)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
315
internal/backup/estimate.go
Normal file
315
internal/backup/estimate.go
Normal file
@ -0,0 +1,315 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/shirou/gopsutil/v3/disk"
|
||||
|
||||
"dbbackup/internal/config"
|
||||
"dbbackup/internal/database"
|
||||
"dbbackup/internal/logger"
|
||||
)
|
||||
|
||||
// SizeEstimate contains backup size estimation results
|
||||
type SizeEstimate struct {
|
||||
DatabaseName string `json:"database_name"`
|
||||
EstimatedRawSize int64 `json:"estimated_raw_size_bytes"`
|
||||
EstimatedCompressed int64 `json:"estimated_compressed_bytes"`
|
||||
CompressionRatio float64 `json:"compression_ratio"`
|
||||
TableCount int `json:"table_count"`
|
||||
LargestTable string `json:"largest_table,omitempty"`
|
||||
LargestTableSize int64 `json:"largest_table_size_bytes,omitempty"`
|
||||
EstimatedDuration time.Duration `json:"estimated_duration"`
|
||||
RecommendedProfile string `json:"recommended_profile"`
|
||||
RequiredDiskSpace int64 `json:"required_disk_space_bytes"`
|
||||
AvailableDiskSpace int64 `json:"available_disk_space_bytes"`
|
||||
HasSufficientSpace bool `json:"has_sufficient_space"`
|
||||
EstimationTime time.Duration `json:"estimation_time"`
|
||||
}
|
||||
|
||||
// ClusterSizeEstimate contains cluster-wide size estimation
|
||||
type ClusterSizeEstimate struct {
|
||||
TotalDatabases int `json:"total_databases"`
|
||||
TotalRawSize int64 `json:"total_raw_size_bytes"`
|
||||
TotalCompressed int64 `json:"total_compressed_bytes"`
|
||||
LargestDatabase string `json:"largest_database,omitempty"`
|
||||
LargestDatabaseSize int64 `json:"largest_database_size_bytes,omitempty"`
|
||||
EstimatedDuration time.Duration `json:"estimated_duration"`
|
||||
RequiredDiskSpace int64 `json:"required_disk_space_bytes"`
|
||||
AvailableDiskSpace int64 `json:"available_disk_space_bytes"`
|
||||
HasSufficientSpace bool `json:"has_sufficient_space"`
|
||||
DatabaseEstimates map[string]*SizeEstimate `json:"database_estimates,omitempty"`
|
||||
EstimationTime time.Duration `json:"estimation_time"`
|
||||
}
|
||||
|
||||
// EstimateBackupSize estimates the size of a single database backup
|
||||
func EstimateBackupSize(ctx context.Context, cfg *config.Config, log logger.Logger, databaseName string) (*SizeEstimate, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
estimate := &SizeEstimate{
|
||||
DatabaseName: databaseName,
|
||||
}
|
||||
|
||||
// Create database connection
|
||||
db, err := database.New(cfg, log)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create database instance: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := db.Connect(ctx); err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
// Get database size based on engine type
|
||||
rawSize, err := db.GetDatabaseSize(ctx, databaseName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get database size: %w", err)
|
||||
}
|
||||
estimate.EstimatedRawSize = rawSize
|
||||
|
||||
// Get table statistics
|
||||
tables, err := db.ListTables(ctx, databaseName)
|
||||
if err == nil {
|
||||
estimate.TableCount = len(tables)
|
||||
}
|
||||
|
||||
// For PostgreSQL and MySQL, get additional detailed statistics
|
||||
if cfg.IsPostgreSQL() {
|
||||
pg := db.(*database.PostgreSQL)
|
||||
if err := estimatePostgresSize(ctx, pg.GetConn(), databaseName, estimate); err != nil {
|
||||
log.Debug("Could not get detailed PostgreSQL stats: %v", err)
|
||||
}
|
||||
} else if cfg.IsMySQL() {
|
||||
my := db.(*database.MySQL)
|
||||
if err := estimateMySQLSize(ctx, my.GetConn(), databaseName, estimate); err != nil {
|
||||
log.Debug("Could not get detailed MySQL stats: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate compression ratio (typical: 70-80% for databases)
|
||||
estimate.CompressionRatio = 0.25 // Assume 75% compression (1/4 of original size)
|
||||
if cfg.CompressionLevel >= 6 {
|
||||
estimate.CompressionRatio = 0.20 // Better compression with higher levels
|
||||
}
|
||||
estimate.EstimatedCompressed = int64(float64(estimate.EstimatedRawSize) * estimate.CompressionRatio)
|
||||
|
||||
// Estimate duration (rough: 50 MB/s for pg_dump, 100 MB/s for mysqldump)
|
||||
throughputMBps := 50.0
|
||||
if cfg.IsMySQL() {
|
||||
throughputMBps = 100.0
|
||||
}
|
||||
|
||||
sizeGB := float64(estimate.EstimatedRawSize) / (1024 * 1024 * 1024)
|
||||
durationMinutes := (sizeGB * 1024) / throughputMBps / 60
|
||||
estimate.EstimatedDuration = time.Duration(durationMinutes * float64(time.Minute))
|
||||
|
||||
// Recommend profile based on size
|
||||
if sizeGB < 1 {
|
||||
estimate.RecommendedProfile = "balanced"
|
||||
} else if sizeGB < 10 {
|
||||
estimate.RecommendedProfile = "performance"
|
||||
} else if sizeGB < 100 {
|
||||
estimate.RecommendedProfile = "turbo"
|
||||
} else {
|
||||
estimate.RecommendedProfile = "conservative" // Large DB, be careful
|
||||
}
|
||||
|
||||
// Calculate required disk space (3x compressed size for safety: temp + compressed + checksum)
|
||||
estimate.RequiredDiskSpace = estimate.EstimatedCompressed * 3
|
||||
|
||||
// Check available disk space
|
||||
if cfg.BackupDir != "" {
|
||||
if usage, err := disk.Usage(cfg.BackupDir); err == nil {
|
||||
estimate.AvailableDiskSpace = int64(usage.Free)
|
||||
estimate.HasSufficientSpace = estimate.AvailableDiskSpace > estimate.RequiredDiskSpace
|
||||
}
|
||||
}
|
||||
|
||||
estimate.EstimationTime = time.Since(startTime)
|
||||
return estimate, nil
|
||||
}
|
||||
|
||||
// EstimateClusterBackupSize estimates the size of a full cluster backup
|
||||
func EstimateClusterBackupSize(ctx context.Context, cfg *config.Config, log logger.Logger) (*ClusterSizeEstimate, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
estimate := &ClusterSizeEstimate{
|
||||
DatabaseEstimates: make(map[string]*SizeEstimate),
|
||||
}
|
||||
|
||||
// Create database connection
|
||||
db, err := database.New(cfg, log)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create database instance: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := db.Connect(ctx); err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
// List all databases
|
||||
databases, err := db.ListDatabases(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list databases: %w", err)
|
||||
}
|
||||
|
||||
estimate.TotalDatabases = len(databases)
|
||||
|
||||
// Estimate each database
|
||||
for _, dbName := range databases {
|
||||
dbEstimate, err := EstimateBackupSize(ctx, cfg, log, dbName)
|
||||
if err != nil {
|
||||
log.Warn("Failed to estimate database size", "database", dbName, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
estimate.DatabaseEstimates[dbName] = dbEstimate
|
||||
estimate.TotalRawSize += dbEstimate.EstimatedRawSize
|
||||
estimate.TotalCompressed += dbEstimate.EstimatedCompressed
|
||||
|
||||
// Track largest database
|
||||
if dbEstimate.EstimatedRawSize > estimate.LargestDatabaseSize {
|
||||
estimate.LargestDatabase = dbName
|
||||
estimate.LargestDatabaseSize = dbEstimate.EstimatedRawSize
|
||||
}
|
||||
}
|
||||
|
||||
// Estimate total duration (assume some parallelism)
|
||||
parallelism := float64(cfg.Jobs)
|
||||
if parallelism < 1 {
|
||||
parallelism = 1
|
||||
}
|
||||
|
||||
// Calculate serial duration first
|
||||
var serialDuration time.Duration
|
||||
for _, dbEst := range estimate.DatabaseEstimates {
|
||||
serialDuration += dbEst.EstimatedDuration
|
||||
}
|
||||
|
||||
// Adjust for parallelism (not perfect but reasonable)
|
||||
estimate.EstimatedDuration = time.Duration(float64(serialDuration) / parallelism)
|
||||
|
||||
// Calculate required disk space
|
||||
estimate.RequiredDiskSpace = estimate.TotalCompressed * 3
|
||||
|
||||
// Check available disk space
|
||||
if cfg.BackupDir != "" {
|
||||
if usage, err := disk.Usage(cfg.BackupDir); err == nil {
|
||||
estimate.AvailableDiskSpace = int64(usage.Free)
|
||||
estimate.HasSufficientSpace = estimate.AvailableDiskSpace > estimate.RequiredDiskSpace
|
||||
}
|
||||
}
|
||||
|
||||
estimate.EstimationTime = time.Since(startTime)
|
||||
return estimate, nil
|
||||
}
|
||||
|
||||
// estimatePostgresSize gets detailed statistics from PostgreSQL
|
||||
func estimatePostgresSize(ctx context.Context, conn *sql.DB, databaseName string, estimate *SizeEstimate) error {
|
||||
// Note: EstimatedRawSize and TableCount are already set by interface methods
|
||||
|
||||
// Get largest table size
|
||||
largestQuery := `
|
||||
SELECT
|
||||
schemaname || '.' || tablename as table_name,
|
||||
pg_total_relation_size(schemaname||'.'||tablename) as size_bytes
|
||||
FROM pg_tables
|
||||
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
|
||||
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC
|
||||
LIMIT 1
|
||||
`
|
||||
var tableName string
|
||||
var tableSize int64
|
||||
if err := conn.QueryRowContext(ctx, largestQuery).Scan(&tableName, &tableSize); err == nil {
|
||||
estimate.LargestTable = tableName
|
||||
estimate.LargestTableSize = tableSize
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// estimateMySQLSize gets detailed statistics from MySQL/MariaDB
|
||||
func estimateMySQLSize(ctx context.Context, conn *sql.DB, databaseName string, estimate *SizeEstimate) error {
|
||||
// Note: EstimatedRawSize and TableCount are already set by interface methods
|
||||
|
||||
// Get largest table
|
||||
largestQuery := `
|
||||
SELECT
|
||||
table_name,
|
||||
data_length + index_length as size_bytes
|
||||
FROM information_schema.TABLES
|
||||
WHERE table_schema = ?
|
||||
ORDER BY (data_length + index_length) DESC
|
||||
LIMIT 1
|
||||
`
|
||||
var tableName string
|
||||
var tableSize int64
|
||||
if err := conn.QueryRowContext(ctx, largestQuery, databaseName).Scan(&tableName, &tableSize); err == nil {
|
||||
estimate.LargestTable = tableName
|
||||
estimate.LargestTableSize = tableSize
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormatSizeEstimate returns a human-readable summary
|
||||
func FormatSizeEstimate(estimate *SizeEstimate) string {
|
||||
return fmt.Sprintf(`Database: %s
|
||||
Raw Size: %s
|
||||
Compressed Size: %s (%.0f%% compression)
|
||||
Tables: %d
|
||||
Largest Table: %s (%s)
|
||||
Estimated Duration: %s
|
||||
Recommended Profile: %s
|
||||
Required Disk Space: %s
|
||||
Available Space: %s
|
||||
Status: %s`,
|
||||
estimate.DatabaseName,
|
||||
formatBytes(estimate.EstimatedRawSize),
|
||||
formatBytes(estimate.EstimatedCompressed),
|
||||
(1.0-estimate.CompressionRatio)*100,
|
||||
estimate.TableCount,
|
||||
estimate.LargestTable,
|
||||
formatBytes(estimate.LargestTableSize),
|
||||
estimate.EstimatedDuration.Round(time.Second),
|
||||
estimate.RecommendedProfile,
|
||||
formatBytes(estimate.RequiredDiskSpace),
|
||||
formatBytes(estimate.AvailableDiskSpace),
|
||||
getSpaceStatus(estimate.HasSufficientSpace))
|
||||
}
|
||||
|
||||
// FormatClusterSizeEstimate returns a human-readable summary
|
||||
func FormatClusterSizeEstimate(estimate *ClusterSizeEstimate) string {
|
||||
return fmt.Sprintf(`Cluster Backup Estimate:
|
||||
Total Databases: %d
|
||||
Total Raw Size: %s
|
||||
Total Compressed: %s
|
||||
Largest Database: %s (%s)
|
||||
Estimated Duration: %s
|
||||
Required Disk Space: %s
|
||||
Available Space: %s
|
||||
Status: %s
|
||||
Estimation Time: %v`,
|
||||
estimate.TotalDatabases,
|
||||
formatBytes(estimate.TotalRawSize),
|
||||
formatBytes(estimate.TotalCompressed),
|
||||
estimate.LargestDatabase,
|
||||
formatBytes(estimate.LargestDatabaseSize),
|
||||
estimate.EstimatedDuration.Round(time.Second),
|
||||
formatBytes(estimate.RequiredDiskSpace),
|
||||
formatBytes(estimate.AvailableDiskSpace),
|
||||
getSpaceStatus(estimate.HasSufficientSpace),
|
||||
estimate.EstimationTime)
|
||||
}
|
||||
|
||||
func getSpaceStatus(hasSufficient bool) string {
|
||||
if hasSufficient {
|
||||
return "✅ Sufficient"
|
||||
}
|
||||
return "⚠️ INSUFFICIENT - Free up space first!"
|
||||
}
|
||||
@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/klauspost/pgzip"
|
||||
|
||||
"dbbackup/internal/fs"
|
||||
"dbbackup/internal/logger"
|
||||
"dbbackup/internal/metadata"
|
||||
)
|
||||
@ -368,8 +369,8 @@ func (e *MySQLIncrementalEngine) CalculateFileChecksum(path string) (string, err
|
||||
|
||||
// createTarGz creates a tar.gz archive with the specified changed files
|
||||
func (e *MySQLIncrementalEngine) createTarGz(ctx context.Context, outputFile string, changedFiles []ChangedFile, config *IncrementalBackupConfig) error {
|
||||
// Create output file
|
||||
outFile, err := os.Create(outputFile)
|
||||
// Create output file with secure permissions (0600)
|
||||
outFile, err := fs.SecureCreate(outputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
|
||||
@ -8,12 +8,14 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/klauspost/pgzip"
|
||||
|
||||
"dbbackup/internal/fs"
|
||||
)
|
||||
|
||||
// createTarGz creates a tar.gz archive with the specified changed files
|
||||
func (e *PostgresIncrementalEngine) createTarGz(ctx context.Context, outputFile string, changedFiles []ChangedFile, config *IncrementalBackupConfig) error {
|
||||
// Create output file
|
||||
outFile, err := os.Create(outputFile)
|
||||
// Create output file with secure permissions (0600)
|
||||
outFile, err := fs.SecureCreate(outputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
|
||||
386
internal/checks/diagnostics.go
Normal file
386
internal/checks/diagnostics.go
Normal file
@ -0,0 +1,386 @@
|
||||
package checks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/shirou/gopsutil/v3/disk"
|
||||
"github.com/shirou/gopsutil/v3/mem"
|
||||
)
|
||||
|
||||
// ErrorContext provides environmental context for debugging errors
|
||||
type ErrorContext struct {
|
||||
// System info
|
||||
AvailableDiskSpace uint64 `json:"available_disk_space"`
|
||||
TotalDiskSpace uint64 `json:"total_disk_space"`
|
||||
DiskUsagePercent float64 `json:"disk_usage_percent"`
|
||||
AvailableMemory uint64 `json:"available_memory"`
|
||||
TotalMemory uint64 `json:"total_memory"`
|
||||
MemoryUsagePercent float64 `json:"memory_usage_percent"`
|
||||
OpenFileDescriptors uint64 `json:"open_file_descriptors,omitempty"`
|
||||
MaxFileDescriptors uint64 `json:"max_file_descriptors,omitempty"`
|
||||
|
||||
// Database info (if connection available)
|
||||
DatabaseVersion string `json:"database_version,omitempty"`
|
||||
MaxConnections int `json:"max_connections,omitempty"`
|
||||
CurrentConnections int `json:"current_connections,omitempty"`
|
||||
MaxLocksPerTxn int `json:"max_locks_per_transaction,omitempty"`
|
||||
SharedMemory string `json:"shared_memory,omitempty"`
|
||||
|
||||
// Network info
|
||||
CanReachDatabase bool `json:"can_reach_database"`
|
||||
DatabaseHost string `json:"database_host,omitempty"`
|
||||
DatabasePort int `json:"database_port,omitempty"`
|
||||
|
||||
// Timing
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
}
|
||||
|
||||
// DiagnosticsReport combines error classification with environmental context
|
||||
type DiagnosticsReport struct {
|
||||
Classification *ErrorClassification `json:"classification"`
|
||||
Context *ErrorContext `json:"context"`
|
||||
Recommendations []string `json:"recommendations"`
|
||||
RootCause string `json:"root_cause,omitempty"`
|
||||
}
|
||||
|
||||
// GatherErrorContext collects environmental information for error diagnosis
|
||||
func GatherErrorContext(backupDir string, db *sql.DB) *ErrorContext {
|
||||
ctx := &ErrorContext{
|
||||
CollectedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Gather disk space information
|
||||
if backupDir != "" {
|
||||
usage, err := disk.Usage(backupDir)
|
||||
if err == nil {
|
||||
ctx.AvailableDiskSpace = usage.Free
|
||||
ctx.TotalDiskSpace = usage.Total
|
||||
ctx.DiskUsagePercent = usage.UsedPercent
|
||||
}
|
||||
}
|
||||
|
||||
// Gather memory information
|
||||
vmStat, err := mem.VirtualMemory()
|
||||
if err == nil {
|
||||
ctx.AvailableMemory = vmStat.Available
|
||||
ctx.TotalMemory = vmStat.Total
|
||||
ctx.MemoryUsagePercent = vmStat.UsedPercent
|
||||
}
|
||||
|
||||
// Gather file descriptor limits (Linux/Unix only)
|
||||
if runtime.GOOS != "windows" {
|
||||
var rLimit syscall.Rlimit
|
||||
if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rLimit); err == nil {
|
||||
ctx.MaxFileDescriptors = rLimit.Cur
|
||||
// Try to get current open FDs (this is platform-specific)
|
||||
if fds, err := countOpenFileDescriptors(); err == nil {
|
||||
ctx.OpenFileDescriptors = fds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gather database-specific context (if connection available)
|
||||
if db != nil {
|
||||
gatherDatabaseContext(db, ctx)
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
// countOpenFileDescriptors counts currently open file descriptors (Linux only)
|
||||
func countOpenFileDescriptors() (uint64, error) {
|
||||
if runtime.GOOS != "linux" {
|
||||
return 0, fmt.Errorf("not supported on %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
pid := os.Getpid()
|
||||
fdDir := fmt.Sprintf("/proc/%d/fd", pid)
|
||||
entries, err := os.ReadDir(fdDir)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint64(len(entries)), nil
|
||||
}
|
||||
|
||||
// gatherDatabaseContext collects PostgreSQL-specific diagnostics
|
||||
func gatherDatabaseContext(db *sql.DB, ctx *ErrorContext) {
|
||||
// Set timeout for diagnostic queries
|
||||
diagCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Get PostgreSQL version
|
||||
var version string
|
||||
if err := db.QueryRowContext(diagCtx, "SELECT version()").Scan(&version); err == nil {
|
||||
// Extract short version (e.g., "PostgreSQL 14.5")
|
||||
parts := strings.Fields(version)
|
||||
if len(parts) >= 2 {
|
||||
ctx.DatabaseVersion = parts[0] + " " + parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
// Get max_connections
|
||||
var maxConns int
|
||||
if err := db.QueryRowContext(diagCtx, "SHOW max_connections").Scan(&maxConns); err == nil {
|
||||
ctx.MaxConnections = maxConns
|
||||
}
|
||||
|
||||
// Get current connections
|
||||
var currConns int
|
||||
query := "SELECT count(*) FROM pg_stat_activity"
|
||||
if err := db.QueryRowContext(diagCtx, query).Scan(&currConns); err == nil {
|
||||
ctx.CurrentConnections = currConns
|
||||
}
|
||||
|
||||
// Get max_locks_per_transaction
|
||||
var maxLocks int
|
||||
if err := db.QueryRowContext(diagCtx, "SHOW max_locks_per_transaction").Scan(&maxLocks); err == nil {
|
||||
ctx.MaxLocksPerTxn = maxLocks
|
||||
}
|
||||
|
||||
// Get shared_buffers
|
||||
var sharedBuffers string
|
||||
if err := db.QueryRowContext(diagCtx, "SHOW shared_buffers").Scan(&sharedBuffers); err == nil {
|
||||
ctx.SharedMemory = sharedBuffers
|
||||
}
|
||||
}
|
||||
|
||||
// DiagnoseError analyzes an error with full environmental context
|
||||
func DiagnoseError(errorMsg string, backupDir string, db *sql.DB) *DiagnosticsReport {
|
||||
classification := ClassifyError(errorMsg)
|
||||
context := GatherErrorContext(backupDir, db)
|
||||
|
||||
report := &DiagnosticsReport{
|
||||
Classification: classification,
|
||||
Context: context,
|
||||
Recommendations: make([]string, 0),
|
||||
}
|
||||
|
||||
// Generate context-specific recommendations
|
||||
generateContextualRecommendations(report)
|
||||
|
||||
// Try to determine root cause
|
||||
report.RootCause = analyzeRootCause(report)
|
||||
|
||||
return report
|
||||
}
|
||||
|
||||
// generateContextualRecommendations creates recommendations based on error + environment
|
||||
func generateContextualRecommendations(report *DiagnosticsReport) {
|
||||
ctx := report.Context
|
||||
classification := report.Classification
|
||||
|
||||
// Disk space recommendations
|
||||
if classification.Category == "disk_space" || ctx.DiskUsagePercent > 90 {
|
||||
report.Recommendations = append(report.Recommendations,
|
||||
fmt.Sprintf("⚠ Disk is %.1f%% full (%s available)",
|
||||
ctx.DiskUsagePercent, formatBytes(ctx.AvailableDiskSpace)))
|
||||
report.Recommendations = append(report.Recommendations,
|
||||
"• Clean up old backups: find /mnt/backups -type f -mtime +30 -delete")
|
||||
report.Recommendations = append(report.Recommendations,
|
||||
"• Enable automatic cleanup: dbbackup cleanup --retention-days 30")
|
||||
}
|
||||
|
||||
// Memory recommendations
|
||||
if ctx.MemoryUsagePercent > 85 {
|
||||
report.Recommendations = append(report.Recommendations,
|
||||
fmt.Sprintf("⚠ Memory is %.1f%% full (%s available)",
|
||||
ctx.MemoryUsagePercent, formatBytes(ctx.AvailableMemory)))
|
||||
report.Recommendations = append(report.Recommendations,
|
||||
"• Consider reducing parallel jobs: --jobs 2")
|
||||
report.Recommendations = append(report.Recommendations,
|
||||
"• Use conservative restore profile: dbbackup restore --profile conservative")
|
||||
}
|
||||
|
||||
// File descriptor recommendations
|
||||
if ctx.OpenFileDescriptors > 0 && ctx.MaxFileDescriptors > 0 {
|
||||
fdUsagePercent := float64(ctx.OpenFileDescriptors) / float64(ctx.MaxFileDescriptors) * 100
|
||||
if fdUsagePercent > 80 {
|
||||
report.Recommendations = append(report.Recommendations,
|
||||
fmt.Sprintf("⚠ File descriptors at %.0f%% (%d/%d used)",
|
||||
fdUsagePercent, ctx.OpenFileDescriptors, ctx.MaxFileDescriptors))
|
||||
report.Recommendations = append(report.Recommendations,
|
||||
"• Increase limit: ulimit -n 8192")
|
||||
report.Recommendations = append(report.Recommendations,
|
||||
"• Or add to /etc/security/limits.conf: dbbackup soft nofile 8192")
|
||||
}
|
||||
}
|
||||
|
||||
// PostgreSQL lock recommendations
|
||||
if classification.Category == "locks" && ctx.MaxLocksPerTxn > 0 {
|
||||
totalLocks := ctx.MaxLocksPerTxn * (ctx.MaxConnections + 100)
|
||||
report.Recommendations = append(report.Recommendations,
|
||||
fmt.Sprintf("Current lock capacity: %d locks (max_locks_per_transaction × max_connections)",
|
||||
totalLocks))
|
||||
|
||||
if ctx.MaxLocksPerTxn < 2048 {
|
||||
report.Recommendations = append(report.Recommendations,
|
||||
fmt.Sprintf("⚠ max_locks_per_transaction is low (%d)", ctx.MaxLocksPerTxn))
|
||||
report.Recommendations = append(report.Recommendations,
|
||||
"• Increase: ALTER SYSTEM SET max_locks_per_transaction = 4096;")
|
||||
report.Recommendations = append(report.Recommendations,
|
||||
"• Then restart PostgreSQL: sudo systemctl restart postgresql")
|
||||
}
|
||||
|
||||
if ctx.MaxConnections < 20 {
|
||||
report.Recommendations = append(report.Recommendations,
|
||||
fmt.Sprintf("⚠ Low max_connections (%d) reduces total lock capacity", ctx.MaxConnections))
|
||||
report.Recommendations = append(report.Recommendations,
|
||||
"• With fewer connections, you need HIGHER max_locks_per_transaction")
|
||||
}
|
||||
}
|
||||
|
||||
// Connection recommendations
|
||||
if classification.Category == "network" && ctx.CurrentConnections > 0 {
|
||||
connUsagePercent := float64(ctx.CurrentConnections) / float64(ctx.MaxConnections) * 100
|
||||
if connUsagePercent > 80 {
|
||||
report.Recommendations = append(report.Recommendations,
|
||||
fmt.Sprintf("⚠ Connection pool at %.0f%% capacity (%d/%d used)",
|
||||
connUsagePercent, ctx.CurrentConnections, ctx.MaxConnections))
|
||||
report.Recommendations = append(report.Recommendations,
|
||||
"• Close idle connections or increase max_connections")
|
||||
}
|
||||
}
|
||||
|
||||
// Version recommendations
|
||||
if classification.Category == "version" && ctx.DatabaseVersion != "" {
|
||||
report.Recommendations = append(report.Recommendations,
|
||||
fmt.Sprintf("Database version: %s", ctx.DatabaseVersion))
|
||||
report.Recommendations = append(report.Recommendations,
|
||||
"• Check backup was created on same or older PostgreSQL version")
|
||||
report.Recommendations = append(report.Recommendations,
|
||||
"• For major version differences, review migration notes")
|
||||
}
|
||||
}
|
||||
|
||||
// analyzeRootCause attempts to determine the root cause based on error + context
|
||||
func analyzeRootCause(report *DiagnosticsReport) string {
|
||||
ctx := report.Context
|
||||
classification := report.Classification
|
||||
|
||||
// Disk space root causes
|
||||
if classification.Category == "disk_space" {
|
||||
if ctx.DiskUsagePercent > 95 {
|
||||
return "Disk is critically full - no space for backup/restore operations"
|
||||
}
|
||||
return "Insufficient disk space for operation"
|
||||
}
|
||||
|
||||
// Lock exhaustion root causes
|
||||
if classification.Category == "locks" {
|
||||
if ctx.MaxLocksPerTxn > 0 && ctx.MaxConnections > 0 {
|
||||
totalLocks := ctx.MaxLocksPerTxn * (ctx.MaxConnections + 100)
|
||||
if totalLocks < 50000 {
|
||||
return fmt.Sprintf("Lock table capacity too low (%d total locks). Likely cause: max_locks_per_transaction (%d) too low for this database size",
|
||||
totalLocks, ctx.MaxLocksPerTxn)
|
||||
}
|
||||
}
|
||||
return "PostgreSQL lock table exhausted - need to increase max_locks_per_transaction"
|
||||
}
|
||||
|
||||
// Memory pressure
|
||||
if ctx.MemoryUsagePercent > 90 {
|
||||
return "System under memory pressure - may cause slow operations or failures"
|
||||
}
|
||||
|
||||
// Connection exhaustion
|
||||
if classification.Category == "network" && ctx.MaxConnections > 0 && ctx.CurrentConnections > 0 {
|
||||
if ctx.CurrentConnections >= ctx.MaxConnections {
|
||||
return "Connection pool exhausted - all connections in use"
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// FormatDiagnosticsReport creates a human-readable diagnostics report
|
||||
func FormatDiagnosticsReport(report *DiagnosticsReport) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("═══════════════════════════════════════════════════════════\n")
|
||||
sb.WriteString(" DBBACKUP ERROR DIAGNOSTICS REPORT\n")
|
||||
sb.WriteString("═══════════════════════════════════════════════════════════\n\n")
|
||||
|
||||
// Error classification
|
||||
sb.WriteString(fmt.Sprintf("Error Type: %s\n", strings.ToUpper(report.Classification.Type)))
|
||||
sb.WriteString(fmt.Sprintf("Category: %s\n", report.Classification.Category))
|
||||
sb.WriteString(fmt.Sprintf("Severity: %d/3\n\n", report.Classification.Severity))
|
||||
|
||||
// Error message
|
||||
sb.WriteString("Message:\n")
|
||||
sb.WriteString(fmt.Sprintf(" %s\n\n", report.Classification.Message))
|
||||
|
||||
// Hint
|
||||
if report.Classification.Hint != "" {
|
||||
sb.WriteString("Hint:\n")
|
||||
sb.WriteString(fmt.Sprintf(" %s\n\n", report.Classification.Hint))
|
||||
}
|
||||
|
||||
// Root cause (if identified)
|
||||
if report.RootCause != "" {
|
||||
sb.WriteString("Root Cause:\n")
|
||||
sb.WriteString(fmt.Sprintf(" %s\n\n", report.RootCause))
|
||||
}
|
||||
|
||||
// System context
|
||||
sb.WriteString("System Context:\n")
|
||||
sb.WriteString(fmt.Sprintf(" Disk Space: %s / %s (%.1f%% used)\n",
|
||||
formatBytes(report.Context.AvailableDiskSpace),
|
||||
formatBytes(report.Context.TotalDiskSpace),
|
||||
report.Context.DiskUsagePercent))
|
||||
sb.WriteString(fmt.Sprintf(" Memory: %s / %s (%.1f%% used)\n",
|
||||
formatBytes(report.Context.AvailableMemory),
|
||||
formatBytes(report.Context.TotalMemory),
|
||||
report.Context.MemoryUsagePercent))
|
||||
|
||||
if report.Context.OpenFileDescriptors > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" File Descriptors: %d / %d\n",
|
||||
report.Context.OpenFileDescriptors,
|
||||
report.Context.MaxFileDescriptors))
|
||||
}
|
||||
|
||||
// Database context
|
||||
if report.Context.DatabaseVersion != "" {
|
||||
sb.WriteString("\nDatabase Context:\n")
|
||||
sb.WriteString(fmt.Sprintf(" Version: %s\n", report.Context.DatabaseVersion))
|
||||
if report.Context.MaxConnections > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" Connections: %d / %d\n",
|
||||
report.Context.CurrentConnections,
|
||||
report.Context.MaxConnections))
|
||||
}
|
||||
if report.Context.MaxLocksPerTxn > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" Max Locks: %d per transaction\n", report.Context.MaxLocksPerTxn))
|
||||
totalLocks := report.Context.MaxLocksPerTxn * (report.Context.MaxConnections + 100)
|
||||
sb.WriteString(fmt.Sprintf(" Total Lock Capacity: ~%d\n", totalLocks))
|
||||
}
|
||||
if report.Context.SharedMemory != "" {
|
||||
sb.WriteString(fmt.Sprintf(" Shared Memory: %s\n", report.Context.SharedMemory))
|
||||
}
|
||||
}
|
||||
|
||||
// Recommendations
|
||||
if len(report.Recommendations) > 0 {
|
||||
sb.WriteString("\nRecommendations:\n")
|
||||
for _, rec := range report.Recommendations {
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", rec))
|
||||
}
|
||||
}
|
||||
|
||||
// Action
|
||||
if report.Classification.Action != "" {
|
||||
sb.WriteString("\nSuggested Action:\n")
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", report.Classification.Action))
|
||||
}
|
||||
|
||||
sb.WriteString("\n═══════════════════════════════════════════════════════════\n")
|
||||
sb.WriteString(fmt.Sprintf("Report generated: %s\n", report.Context.CollectedAt.Format("2006-01-02 15:04:05")))
|
||||
sb.WriteString("═══════════════════════════════════════════════════════════\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
@ -312,8 +312,8 @@ func (a *AzureBackend) Download(ctx context.Context, remotePath, localPath strin
|
||||
// Wrap reader with progress tracking
|
||||
reader := NewProgressReader(resp.Body, fileSize, progress)
|
||||
|
||||
// Copy with progress
|
||||
_, err = io.Copy(file, reader)
|
||||
// Copy with progress and context awareness
|
||||
_, err = CopyWithContext(ctx, file, reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
@ -128,8 +128,8 @@ func (g *GCSBackend) Upload(ctx context.Context, localPath, remotePath string, p
|
||||
reader = NewThrottledReader(ctx, reader, g.config.BandwidthLimit)
|
||||
}
|
||||
|
||||
// Upload with progress tracking
|
||||
_, err = io.Copy(writer, reader)
|
||||
// Upload with progress tracking and context awareness
|
||||
_, err = CopyWithContext(ctx, writer, reader)
|
||||
if err != nil {
|
||||
writer.Close()
|
||||
return fmt.Errorf("failed to upload object: %w", err)
|
||||
@ -191,8 +191,8 @@ func (g *GCSBackend) Download(ctx context.Context, remotePath, localPath string,
|
||||
// Wrap reader with progress tracking
|
||||
progressReader := NewProgressReader(reader, fileSize, progress)
|
||||
|
||||
// Copy with progress
|
||||
_, err = io.Copy(file, progressReader)
|
||||
// Copy with progress and context awareness
|
||||
_, err = CopyWithContext(ctx, file, progressReader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
@ -170,3 +170,39 @@ func (pr *ProgressReader) Read(p []byte) (int, error) {
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
// CopyWithContext copies data from src to dst while checking for context cancellation.
|
||||
// This allows Ctrl+C to interrupt large file transfers instead of blocking until complete.
|
||||
// Checks context every 1MB of data copied for responsive interruption.
|
||||
func CopyWithContext(ctx context.Context, dst io.Writer, src io.Reader) (int64, error) {
|
||||
buf := make([]byte, 1024*1024) // 1MB buffer - check context every 1MB
|
||||
var written int64
|
||||
for {
|
||||
// Check for cancellation before each read
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return written, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
nr, readErr := src.Read(buf)
|
||||
if nr > 0 {
|
||||
nw, writeErr := dst.Write(buf[:nr])
|
||||
if nw > 0 {
|
||||
written += int64(nw)
|
||||
}
|
||||
if writeErr != nil {
|
||||
return written, writeErr
|
||||
}
|
||||
if nr != nw {
|
||||
return written, io.ErrShortWrite
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
if readErr == io.EOF {
|
||||
return written, nil
|
||||
}
|
||||
return written, readErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -256,7 +256,7 @@ func (s *S3Backend) Download(ctx context.Context, remotePath, localPath string,
|
||||
reader = NewProgressReader(result.Body, size, progress)
|
||||
}
|
||||
|
||||
_, err = io.Copy(outFile, reader)
|
||||
_, err = CopyWithContext(ctx, outFile, reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
@ -84,6 +84,9 @@ type Config struct {
|
||||
SwapFileSizeGB int // Size in GB (0 = disabled)
|
||||
AutoSwap bool // Automatically manage swap for large backups
|
||||
|
||||
// Backup verification (HIGH priority - #9)
|
||||
VerifyAfterBackup bool // Automatically verify backup integrity after creation (default: true)
|
||||
|
||||
// Security options (MEDIUM priority)
|
||||
RetentionDays int // Backup retention in days (0 = disabled)
|
||||
MinBackups int // Minimum backups to keep regardless of age
|
||||
@ -253,6 +256,9 @@ func New() *Config {
|
||||
SwapFileSizeGB: getEnvInt("SWAP_FILE_SIZE_GB", 0), // 0 = disabled by default
|
||||
AutoSwap: getEnvBool("AUTO_SWAP", false),
|
||||
|
||||
// Backup verification defaults
|
||||
VerifyAfterBackup: getEnvBool("VERIFY_AFTER_BACKUP", true), // Auto-verify by default (HIGH priority #9)
|
||||
|
||||
// Security defaults (MEDIUM priority)
|
||||
RetentionDays: getEnvInt("RETENTION_DAYS", 30), // Keep backups for 30 days
|
||||
MinBackups: getEnvInt("MIN_BACKUPS", 5), // Keep at least 5 backups
|
||||
|
||||
@ -117,6 +117,10 @@ func (b *baseDatabase) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *baseDatabase) GetConn() *sql.DB {
|
||||
return b.db
|
||||
}
|
||||
|
||||
func (b *baseDatabase) Ping(ctx context.Context) error {
|
||||
if b.db == nil {
|
||||
return fmt.Errorf("database not connected")
|
||||
|
||||
@ -339,8 +339,9 @@ func (p *PostgreSQL) BuildBackupCommand(database, outputFile string, options Bac
|
||||
cmd = append(cmd, "--compress="+strconv.Itoa(options.Compression))
|
||||
}
|
||||
|
||||
// Parallel jobs (only for directory format)
|
||||
if options.Parallel > 1 && options.Format == "directory" {
|
||||
// Parallel jobs (supported for directory and custom formats since PostgreSQL 9.3)
|
||||
// NOTE: plain format does NOT support --jobs (it's single-threaded by design)
|
||||
if options.Parallel > 1 && (options.Format == "directory" || options.Format == "custom") {
|
||||
cmd = append(cmd, "--jobs="+strconv.Itoa(options.Parallel))
|
||||
}
|
||||
|
||||
|
||||
@ -4,12 +4,12 @@ package drill
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"dbbackup/internal/fs"
|
||||
"dbbackup/internal/logger"
|
||||
|
||||
"github.com/klauspost/pgzip"
|
||||
@ -267,7 +267,9 @@ func (e *Engine) decompressWithPgzip(srcPath string) (string, error) {
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
if _, err := io.Copy(dstFile, gz); err != nil {
|
||||
// Use context.Background() since decompressWithPgzip doesn't take context
|
||||
// The parent restoreBackup function handles context cancellation
|
||||
if _, err := fs.CopyWithContext(context.Background(), dstFile, gz); err != nil {
|
||||
os.Remove(dstPath)
|
||||
return "", fmt.Errorf("decompression failed: %w", err)
|
||||
}
|
||||
|
||||
127
internal/exitcode/codes.go
Normal file
127
internal/exitcode/codes.go
Normal file
@ -0,0 +1,127 @@
|
||||
package exitcode
|
||||
package exitcode
|
||||
|
||||
// Standard exit codes following BSD sysexits.h conventions
|
||||
// See: https://man.freebsd.org/cgi/man.cgi?query=sysexits
|
||||
const (
|
||||
// Success - operation completed successfully
|
||||
Success = 0
|
||||
|
||||
// General - general error (fallback)
|
||||
General = 1
|
||||
|
||||
// UsageError - command line usage error
|
||||
UsageError = 2
|
||||
|
||||
// DataError - input data was incorrect
|
||||
DataError = 65
|
||||
|
||||
// NoInput - input file did not exist or was not readable
|
||||
NoInput = 66
|
||||
|
||||
// NoHost - host name unknown (for network operations)
|
||||
NoHost = 68
|
||||
|
||||
// Unavailable - service unavailable (database unreachable)
|
||||
Unavailable = 69
|
||||
|
||||
// Software - internal software error
|
||||
Software = 70
|
||||
|
||||
// OSError - operating system error (file I/O, etc.)
|
||||
OSError = 71
|
||||
|
||||
// OSFile - critical OS file missing
|
||||
OSFile = 72
|
||||
|
||||
// CantCreate - can't create output file
|
||||
CantCreate = 73
|
||||
|
||||
// IOError - error during I/O operation
|
||||
IOError = 74
|
||||
|
||||
// TempFail - temporary failure, user can retry
|
||||
TempFail = 75
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
} return false } } } } return true if str[i:i+len(substr)] == substr { for i := 0; i <= len(str)-len(substr); i++ { if len(str) >= len(substr) { for _, substr := range substrs {func contains(str string, substrs ...string) bool {} return General // Default to general error } return DataError if contains(errMsg, "corrupted", "truncated", "invalid archive", "bad format") { // Corrupted data } return Config if contains(errMsg, "invalid config", "configuration error", "bad config") { // Configuration errors } return Cancelled if contains(errMsg, "context canceled", "operation canceled", "cancelled") { // Cancelled errors } return Timeout if contains(errMsg, "timeout", "timed out", "deadline exceeded") { // Timeout errors } return IOError if contains(errMsg, "no space left", "disk full", "i/o error", "read-only file system") { // Disk full / I/O errors } return NoInput if contains(errMsg, "no such file", "file not found", "does not exist") { // File not found } return Unavailable if contains(errMsg, "connection refused", "could not connect", "no such host", "unknown host") { // Connection errors } return NoPerm if contains(errMsg, "permission denied", "access denied", "authentication failed", "FATAL: password authentication") { // Authentication/Permission errors errMsg := err.Error() // Check error message for common patterns } return Success if err == nil {func ExitWithCode(err error) int {// ExitWithCode exits with appropriate code based on error type) Cancelled = 130 // Cancelled - operation cancelled by user (Ctrl+C) Timeout = 124 // Timeout - operation timeout Config = 78 // Config - configuration error NoPerm = 77 // NoPerm - permission denied Protocol = 76 // Protocol - remote error in protocol
|
||||
@ -14,6 +14,42 @@ import (
|
||||
"github.com/klauspost/pgzip"
|
||||
)
|
||||
|
||||
// CopyWithContext copies data from src to dst while checking for context cancellation.
|
||||
// This allows Ctrl+C to interrupt large file extractions instead of blocking until complete.
|
||||
// Checks context every 1MB of data copied for responsive interruption.
|
||||
func CopyWithContext(ctx context.Context, dst io.Writer, src io.Reader) (int64, error) {
|
||||
buf := make([]byte, 1024*1024) // 1MB buffer - check context every 1MB
|
||||
var written int64
|
||||
for {
|
||||
// Check for cancellation before each read
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return written, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
nr, readErr := src.Read(buf)
|
||||
if nr > 0 {
|
||||
nw, writeErr := dst.Write(buf[:nr])
|
||||
if nw > 0 {
|
||||
written += int64(nw)
|
||||
}
|
||||
if writeErr != nil {
|
||||
return written, writeErr
|
||||
}
|
||||
if nr != nw {
|
||||
return written, io.ErrShortWrite
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
if readErr == io.EOF {
|
||||
return written, nil
|
||||
}
|
||||
return written, readErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ParallelGzipWriter wraps pgzip.Writer for streaming compression
|
||||
type ParallelGzipWriter struct {
|
||||
*pgzip.Writer
|
||||
@ -134,11 +170,13 @@ func ExtractTarGzParallel(ctx context.Context, archivePath, destDir string, prog
|
||||
return fmt.Errorf("cannot create file %s: %w", targetPath, err)
|
||||
}
|
||||
|
||||
// Copy with size limit to prevent zip bombs
|
||||
written, err := io.Copy(outFile, tarReader)
|
||||
// Copy with context awareness to allow Ctrl+C interruption during large file extraction
|
||||
written, err := CopyWithContext(ctx, outFile, tarReader)
|
||||
outFile.Close()
|
||||
|
||||
if err != nil {
|
||||
// Clean up partial file on error
|
||||
os.Remove(targetPath)
|
||||
return fmt.Errorf("error writing %s: %w", targetPath, err)
|
||||
}
|
||||
|
||||
|
||||
78
internal/fs/secure.go
Normal file
78
internal/fs/secure.go
Normal file
@ -0,0 +1,78 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// SecureMkdirAll creates directories with secure permissions, handling race conditions
|
||||
// Uses 0700 permissions (owner-only access) for sensitive data directories
|
||||
func SecureMkdirAll(path string, perm os.FileMode) error {
|
||||
err := os.MkdirAll(path, perm)
|
||||
if err != nil && !errors.Is(err, os.ErrExist) {
|
||||
return fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SecureCreate creates a file with secure permissions (0600 - owner read/write only)
|
||||
// Used for backup files containing sensitive database data
|
||||
func SecureCreate(path string) (*os.File, error) {
|
||||
return os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||
}
|
||||
|
||||
// SecureOpenFile opens a file with specified flags and secure permissions
|
||||
func SecureOpenFile(path string, flag int, perm os.FileMode) (*os.File, error) {
|
||||
// Ensure permission is restrictive for new files
|
||||
if flag&os.O_CREATE != 0 && perm > 0600 {
|
||||
perm = 0600
|
||||
}
|
||||
return os.OpenFile(path, flag, perm)
|
||||
}
|
||||
|
||||
// SecureMkdirTemp creates a temporary directory with 0700 permissions
|
||||
// Returns absolute path to created directory
|
||||
func SecureMkdirTemp(dir, pattern string) (string, error) {
|
||||
if dir == "" {
|
||||
dir = os.TempDir()
|
||||
}
|
||||
|
||||
tempDir, err := os.MkdirTemp(dir, pattern)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create temp directory: %w", err)
|
||||
}
|
||||
|
||||
// Ensure temp directory has secure permissions
|
||||
if err := os.Chmod(tempDir, 0700); err != nil {
|
||||
os.RemoveAll(tempDir)
|
||||
return "", fmt.Errorf("failed to secure temp directory: %w", err)
|
||||
}
|
||||
|
||||
return tempDir, nil
|
||||
}
|
||||
|
||||
// CheckWriteAccess tests if directory is writable by creating and removing a test file
|
||||
// Returns error if directory is not writable (e.g., read-only filesystem)
|
||||
func CheckWriteAccess(dir string) error {
|
||||
testFile := filepath.Join(dir, ".dbbackup-write-test")
|
||||
|
||||
f, err := os.Create(testFile)
|
||||
if err != nil {
|
||||
if os.IsPermission(err) {
|
||||
return fmt.Errorf("directory is not writable (permission denied): %s", dir)
|
||||
}
|
||||
if errors.Is(err, os.ErrPermission) {
|
||||
return fmt.Errorf("directory is read-only: %s", dir)
|
||||
}
|
||||
return fmt.Errorf("cannot write to directory: %w", err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
if err := os.Remove(testFile); err != nil {
|
||||
return fmt.Errorf("cannot remove test file (directory may be read-only): %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -291,37 +291,3 @@ func GetMemoryStatus() (*MemoryStatus, error) {
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// SecureMkdirTemp creates a temporary directory with secure permissions (0700)
|
||||
// This prevents other users from reading sensitive database dump contents
|
||||
// Uses the specified baseDir, or os.TempDir() if empty
|
||||
func SecureMkdirTemp(baseDir, pattern string) (string, error) {
|
||||
if baseDir == "" {
|
||||
baseDir = os.TempDir()
|
||||
}
|
||||
|
||||
// Use os.MkdirTemp for unique naming
|
||||
dir, err := os.MkdirTemp(baseDir, pattern)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Ensure secure permissions (0700 = owner read/write/execute only)
|
||||
if err := os.Chmod(dir, 0700); err != nil {
|
||||
// Try to clean up if we can't secure it
|
||||
os.Remove(dir)
|
||||
return "", fmt.Errorf("cannot set secure permissions: %w", err)
|
||||
}
|
||||
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// SecureWriteFile writes content to a file with secure permissions (0600)
|
||||
// This prevents other users from reading sensitive data
|
||||
func SecureWriteFile(filename string, data []byte) error {
|
||||
// Write with restrictive permissions
|
||||
if err := os.WriteFile(filename, data, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
// Ensure permissions are correct
|
||||
return os.Chmod(filename, 0600)
|
||||
}
|
||||
|
||||
@ -8,10 +8,13 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/klauspost/pgzip"
|
||||
)
|
||||
|
||||
// Table represents a database table
|
||||
@ -599,21 +602,19 @@ func escapeString(s string) string {
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// gzipWriter wraps compress/gzip
|
||||
// gzipWriter wraps pgzip for parallel compression
|
||||
type gzipWriter struct {
|
||||
io.WriteCloser
|
||||
*pgzip.Writer
|
||||
}
|
||||
|
||||
func newGzipWriter(w io.Writer) (*gzipWriter, error) {
|
||||
// Import would be: import "compress/gzip"
|
||||
// For now, return a passthrough (actual implementation would use gzip)
|
||||
return &gzipWriter{
|
||||
WriteCloser: &nopCloser{w},
|
||||
}, nil
|
||||
gz, err := pgzip.NewWriterLevel(w, pgzip.BestSpeed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create pgzip writer: %w", err)
|
||||
}
|
||||
// Use all CPUs for parallel compression
|
||||
if err := gz.SetConcurrency(256*1024, runtime.NumCPU()); err != nil {
|
||||
// Non-fatal, continue with defaults
|
||||
}
|
||||
return &gzipWriter{Writer: gz}, nil
|
||||
}
|
||||
|
||||
type nopCloser struct {
|
||||
io.Writer
|
||||
}
|
||||
|
||||
func (n *nopCloser) Close() error { return nil }
|
||||
|
||||
@ -743,7 +743,7 @@ func (e *Engine) executeRestoreWithDecompression(ctx context.Context, archivePat
|
||||
// Stream decompressed data to restore command in goroutine
|
||||
copyDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, copyErr := io.Copy(stdin, gz)
|
||||
_, copyErr := fs.CopyWithContext(ctx, stdin, gz)
|
||||
stdin.Close()
|
||||
copyDone <- copyErr
|
||||
}()
|
||||
@ -853,7 +853,7 @@ func (e *Engine) executeRestoreWithPgzipStream(ctx context.Context, archivePath,
|
||||
// Stream decompressed data to restore command in goroutine
|
||||
copyDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, copyErr := io.Copy(stdin, gz)
|
||||
_, copyErr := fs.CopyWithContext(ctx, stdin, gz)
|
||||
stdin.Close()
|
||||
copyDone <- copyErr
|
||||
}()
|
||||
@ -1907,20 +1907,24 @@ func (e *Engine) extractArchiveWithProgress(ctx context.Context, archivePath, de
|
||||
return fmt.Errorf("failed to create file %s: %w", targetPath, err)
|
||||
}
|
||||
|
||||
// Copy file contents - use buffered I/O for turbo mode (32KB buffer)
|
||||
// Copy file contents with context awareness for Ctrl+C interruption
|
||||
// Use buffered I/O for turbo mode (32KB buffer)
|
||||
if e.cfg.BufferedIO {
|
||||
bufferedWriter := bufio.NewWriterSize(outFile, 32*1024) // 32KB buffer for faster writes
|
||||
if _, err := io.Copy(bufferedWriter, tarReader); err != nil {
|
||||
if _, err := fs.CopyWithContext(ctx, bufferedWriter, tarReader); err != nil {
|
||||
outFile.Close()
|
||||
os.Remove(targetPath) // Clean up partial file
|
||||
return fmt.Errorf("failed to write file %s: %w", targetPath, err)
|
||||
}
|
||||
if err := bufferedWriter.Flush(); err != nil {
|
||||
outFile.Close()
|
||||
os.Remove(targetPath)
|
||||
return fmt.Errorf("failed to flush buffer for %s: %w", targetPath, err)
|
||||
}
|
||||
} else {
|
||||
if _, err := io.Copy(outFile, tarReader); err != nil {
|
||||
if _, err := fs.CopyWithContext(ctx, outFile, tarReader); err != nil {
|
||||
outFile.Close()
|
||||
os.Remove(targetPath) // Clean up partial file
|
||||
return fmt.Errorf("failed to write file %s: %w", targetPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"dbbackup/internal/fs"
|
||||
"dbbackup/internal/logger"
|
||||
"dbbackup/internal/progress"
|
||||
|
||||
@ -23,6 +24,61 @@ type DatabaseInfo struct {
|
||||
Size int64
|
||||
}
|
||||
|
||||
// ListDatabasesFromExtractedDir lists databases from an already-extracted cluster directory
|
||||
// This is much faster than scanning the tar.gz archive
|
||||
func ListDatabasesFromExtractedDir(ctx context.Context, extractedDir string, log logger.Logger) ([]DatabaseInfo, error) {
|
||||
dumpsDir := filepath.Join(extractedDir, "dumps")
|
||||
entries, err := os.ReadDir(dumpsDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read dumps directory: %w", err)
|
||||
}
|
||||
|
||||
databases := make([]DatabaseInfo, 0)
|
||||
for _, entry := range entries {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
filename := entry.Name()
|
||||
// Extract database name from filename
|
||||
dbName := filename
|
||||
dbName = strings.TrimSuffix(dbName, ".dump.gz")
|
||||
dbName = strings.TrimSuffix(dbName, ".dump")
|
||||
dbName = strings.TrimSuffix(dbName, ".sql.gz")
|
||||
dbName = strings.TrimSuffix(dbName, ".sql")
|
||||
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
log.Warn("Cannot stat dump file", "file", filename, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
databases = append(databases, DatabaseInfo{
|
||||
Name: dbName,
|
||||
Filename: filename,
|
||||
Size: info.Size(),
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by name for consistent output
|
||||
sort.Slice(databases, func(i, j int) bool {
|
||||
return databases[i].Name < databases[j].Name
|
||||
})
|
||||
|
||||
if len(databases) == 0 {
|
||||
return nil, fmt.Errorf("no databases found in extracted directory")
|
||||
}
|
||||
|
||||
log.Info("Listed databases from extracted directory", "count", len(databases))
|
||||
return databases, nil
|
||||
}
|
||||
|
||||
// ListDatabasesInCluster lists all databases in a cluster backup archive
|
||||
func ListDatabasesInCluster(ctx context.Context, archivePath string, log logger.Logger) ([]DatabaseInfo, error) {
|
||||
file, err := os.Open(archivePath)
|
||||
@ -180,10 +236,11 @@ func ExtractDatabaseFromCluster(ctx context.Context, archivePath, dbName, output
|
||||
prog.Update(fmt.Sprintf("Extracting: %s", filename))
|
||||
}
|
||||
|
||||
written, err := io.Copy(outFile, tarReader)
|
||||
written, err := fs.CopyWithContext(ctx, outFile, tarReader)
|
||||
outFile.Close()
|
||||
if err != nil {
|
||||
close(stopTicker)
|
||||
os.Remove(extractedPath) // Clean up partial file
|
||||
return "", fmt.Errorf("extraction failed: %w", err)
|
||||
}
|
||||
|
||||
@ -309,10 +366,11 @@ func ExtractMultipleDatabasesFromCluster(ctx context.Context, archivePath string
|
||||
prog.Update(fmt.Sprintf("Extracting: %s (%d/%d)", dbName, len(extractedPaths)+1, len(dbNames)))
|
||||
}
|
||||
|
||||
written, err := io.Copy(outFile, tarReader)
|
||||
written, err := fs.CopyWithContext(ctx, outFile, tarReader)
|
||||
outFile.Close()
|
||||
if err != nil {
|
||||
close(stopTicker)
|
||||
os.Remove(extractedPath) // Clean up partial file
|
||||
return nil, fmt.Errorf("extraction failed for %s: %w", dbName, err)
|
||||
}
|
||||
|
||||
|
||||
@ -262,11 +262,11 @@ func containsSQLKeywords(content string) bool {
|
||||
// ValidateAndExtractCluster performs validation and pre-extraction for cluster restore
|
||||
// Returns path to extracted directory (in temp location) to avoid double-extraction
|
||||
// Caller must clean up the returned directory with os.RemoveAll() when done
|
||||
// NOTE: Caller should call ValidateArchive() before this function if validation is needed
|
||||
// This avoids redundant gzip header reads which can be slow on large archives
|
||||
func (s *Safety) ValidateAndExtractCluster(ctx context.Context, archivePath string) (extractedDir string, err error) {
|
||||
// First validate archive integrity (fast stream check)
|
||||
if err := s.ValidateArchive(archivePath); err != nil {
|
||||
return "", fmt.Errorf("archive validation failed: %w", err)
|
||||
}
|
||||
// Skip redundant validation here - caller already validated via ValidateArchive()
|
||||
// Opening gzip multiple times is expensive on large archives
|
||||
|
||||
// Create temp directory for extraction in configured WorkDir
|
||||
workDir := s.cfg.GetEffectiveWorkDir()
|
||||
|
||||
@ -46,6 +46,7 @@ type ArchiveInfo struct {
|
||||
DatabaseName string
|
||||
Valid bool
|
||||
ValidationMsg string
|
||||
ExtractedDir string // Pre-extracted cluster directory (optimization)
|
||||
}
|
||||
|
||||
// ArchiveBrowserModel for browsing and selecting backup archives
|
||||
|
||||
@ -14,19 +14,20 @@ import (
|
||||
|
||||
// ClusterDatabaseSelectorModel for selecting databases from a cluster backup
|
||||
type ClusterDatabaseSelectorModel struct {
|
||||
config *config.Config
|
||||
logger logger.Logger
|
||||
parent tea.Model
|
||||
ctx context.Context
|
||||
archive ArchiveInfo
|
||||
databases []restore.DatabaseInfo
|
||||
cursor int
|
||||
selected map[int]bool // Track multiple selections
|
||||
loading bool
|
||||
err error
|
||||
title string
|
||||
mode string // "single" or "multiple"
|
||||
extractOnly bool // If true, extract without restoring
|
||||
config *config.Config
|
||||
logger logger.Logger
|
||||
parent tea.Model
|
||||
ctx context.Context
|
||||
archive ArchiveInfo
|
||||
databases []restore.DatabaseInfo
|
||||
cursor int
|
||||
selected map[int]bool // Track multiple selections
|
||||
loading bool
|
||||
err error
|
||||
title string
|
||||
mode string // "single" or "multiple"
|
||||
extractOnly bool // If true, extract without restoring
|
||||
extractedDir string // Pre-extracted cluster directory (optimization)
|
||||
}
|
||||
|
||||
func NewClusterDatabaseSelector(cfg *config.Config, log logger.Logger, parent tea.Model, ctx context.Context, archive ArchiveInfo, mode string, extractOnly bool) ClusterDatabaseSelectorModel {
|
||||
@ -46,21 +47,38 @@ func NewClusterDatabaseSelector(cfg *config.Config, log logger.Logger, parent te
|
||||
}
|
||||
|
||||
func (m ClusterDatabaseSelectorModel) Init() tea.Cmd {
|
||||
return fetchClusterDatabases(m.ctx, m.archive, m.logger)
|
||||
return fetchClusterDatabases(m.ctx, m.archive, m.config, m.logger)
|
||||
}
|
||||
|
||||
type clusterDatabaseListMsg struct {
|
||||
databases []restore.DatabaseInfo
|
||||
err error
|
||||
databases []restore.DatabaseInfo
|
||||
err error
|
||||
extractedDir string // Path to extracted directory (for reuse)
|
||||
}
|
||||
|
||||
func fetchClusterDatabases(ctx context.Context, archive ArchiveInfo, log logger.Logger) tea.Cmd {
|
||||
func fetchClusterDatabases(ctx context.Context, archive ArchiveInfo, cfg *config.Config, log logger.Logger) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
databases, err := restore.ListDatabasesInCluster(ctx, archive.Path, log)
|
||||
// OPTIMIZATION: Extract archive ONCE, then list databases from disk
|
||||
// This eliminates double-extraction (scan + restore)
|
||||
log.Info("Pre-extracting cluster archive for database listing")
|
||||
safety := restore.NewSafety(cfg, log)
|
||||
extractedDir, err := safety.ValidateAndExtractCluster(ctx, archive.Path)
|
||||
if err != nil {
|
||||
return clusterDatabaseListMsg{databases: nil, err: fmt.Errorf("failed to list databases: %w", err)}
|
||||
// Fallback to direct tar scan if extraction fails
|
||||
log.Warn("Pre-extraction failed, falling back to tar scan", "error", err)
|
||||
databases, err := restore.ListDatabasesInCluster(ctx, archive.Path, log)
|
||||
if err != nil {
|
||||
return clusterDatabaseListMsg{databases: nil, err: fmt.Errorf("failed to list databases: %w", err), extractedDir: ""}
|
||||
}
|
||||
return clusterDatabaseListMsg{databases: databases, err: nil, extractedDir: ""}
|
||||
}
|
||||
return clusterDatabaseListMsg{databases: databases, err: nil}
|
||||
|
||||
// List databases from extracted directory (fast!)
|
||||
databases, err := restore.ListDatabasesFromExtractedDir(ctx, extractedDir, log)
|
||||
if err != nil {
|
||||
return clusterDatabaseListMsg{databases: nil, err: fmt.Errorf("failed to list databases from extracted dir: %w", err), extractedDir: extractedDir}
|
||||
}
|
||||
return clusterDatabaseListMsg{databases: databases, err: nil, extractedDir: extractedDir}
|
||||
}
|
||||
}
|
||||
|
||||
@ -72,6 +90,7 @@ func (m ClusterDatabaseSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.err = msg.err
|
||||
} else {
|
||||
m.databases = msg.databases
|
||||
m.extractedDir = msg.extractedDir // Store for later reuse
|
||||
if len(m.databases) > 0 && m.mode == "single" {
|
||||
m.selected[0] = true // Pre-select first database in single mode
|
||||
}
|
||||
@ -146,6 +165,7 @@ func (m ClusterDatabaseSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
Size: selectedDBs[0].Size,
|
||||
Modified: m.archive.Modified,
|
||||
DatabaseName: selectedDBs[0].Name,
|
||||
ExtractedDir: m.extractedDir, // Pass pre-extracted directory
|
||||
}
|
||||
|
||||
preview := NewRestorePreview(m.config, m.logger, m.parent, m.ctx, dbArchive, "restore-cluster-single")
|
||||
|
||||
@ -30,6 +30,9 @@ type DetailedProgress struct {
|
||||
IsComplete bool
|
||||
IsFailed bool
|
||||
ErrorMessage string
|
||||
|
||||
// Throttling (memory optimization for long operations)
|
||||
lastSampleTime time.Time // Last time we added a speed sample
|
||||
}
|
||||
|
||||
type speedSample struct {
|
||||
@ -84,15 +87,18 @@ func (dp *DetailedProgress) Add(n int64) {
|
||||
dp.Current += n
|
||||
dp.LastUpdate = time.Now()
|
||||
|
||||
// Add speed sample
|
||||
dp.SpeedWindow = append(dp.SpeedWindow, speedSample{
|
||||
timestamp: dp.LastUpdate,
|
||||
bytes: dp.Current,
|
||||
})
|
||||
// Throttle speed samples to max 10/sec (prevent memory bloat in long operations)
|
||||
if dp.LastUpdate.Sub(dp.lastSampleTime) >= 100*time.Millisecond {
|
||||
dp.SpeedWindow = append(dp.SpeedWindow, speedSample{
|
||||
timestamp: dp.LastUpdate,
|
||||
bytes: dp.Current,
|
||||
})
|
||||
dp.lastSampleTime = dp.LastUpdate
|
||||
|
||||
// Keep only last 20 samples for speed calculation
|
||||
if len(dp.SpeedWindow) > 20 {
|
||||
dp.SpeedWindow = dp.SpeedWindow[len(dp.SpeedWindow)-20:]
|
||||
// Keep only last 20 samples for speed calculation
|
||||
if len(dp.SpeedWindow) > 20 {
|
||||
dp.SpeedWindow = dp.SpeedWindow[len(dp.SpeedWindow)-20:]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -104,14 +110,17 @@ func (dp *DetailedProgress) Set(n int64) {
|
||||
dp.Current = n
|
||||
dp.LastUpdate = time.Now()
|
||||
|
||||
// Add speed sample
|
||||
dp.SpeedWindow = append(dp.SpeedWindow, speedSample{
|
||||
timestamp: dp.LastUpdate,
|
||||
bytes: dp.Current,
|
||||
})
|
||||
// Throttle speed samples to max 10/sec (prevent memory bloat in long operations)
|
||||
if dp.LastUpdate.Sub(dp.lastSampleTime) >= 100*time.Millisecond {
|
||||
dp.SpeedWindow = append(dp.SpeedWindow, speedSample{
|
||||
timestamp: dp.LastUpdate,
|
||||
bytes: dp.Current,
|
||||
})
|
||||
dp.lastSampleTime = dp.LastUpdate
|
||||
|
||||
if len(dp.SpeedWindow) > 20 {
|
||||
dp.SpeedWindow = dp.SpeedWindow[len(dp.SpeedWindow)-20:]
|
||||
if len(dp.SpeedWindow) > 20 {
|
||||
dp.SpeedWindow = dp.SpeedWindow[len(dp.SpeedWindow)-20:]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -172,6 +172,10 @@ type sharedProgressState struct {
|
||||
|
||||
// Rolling window for speed calculation
|
||||
speedSamples []restoreSpeedSample
|
||||
|
||||
// Throttling to prevent excessive updates (memory optimization)
|
||||
lastSpeedSampleTime time.Time // Last time we added a speed sample
|
||||
minSampleInterval time.Duration // Minimum interval between samples (100ms)
|
||||
}
|
||||
|
||||
type restoreSpeedSample struct {
|
||||
@ -344,14 +348,21 @@ func executeRestoreWithTUIProgress(parentCtx context.Context, cfg *config.Config
|
||||
progressState.overallPhase = 2
|
||||
}
|
||||
|
||||
// Add speed sample for rolling window calculation
|
||||
progressState.speedSamples = append(progressState.speedSamples, restoreSpeedSample{
|
||||
timestamp: time.Now(),
|
||||
bytes: current,
|
||||
})
|
||||
// Keep only last 100 samples
|
||||
if len(progressState.speedSamples) > 100 {
|
||||
progressState.speedSamples = progressState.speedSamples[len(progressState.speedSamples)-100:]
|
||||
// Throttle speed samples to prevent memory bloat (max 10 samples/sec)
|
||||
now := time.Now()
|
||||
if progressState.minSampleInterval == 0 {
|
||||
progressState.minSampleInterval = 100 * time.Millisecond
|
||||
}
|
||||
if now.Sub(progressState.lastSpeedSampleTime) >= progressState.minSampleInterval {
|
||||
progressState.speedSamples = append(progressState.speedSamples, restoreSpeedSample{
|
||||
timestamp: now,
|
||||
bytes: current,
|
||||
})
|
||||
progressState.lastSpeedSampleTime = now
|
||||
// Keep only last 100 samples (max 10 seconds of history)
|
||||
if len(progressState.speedSamples) > 100 {
|
||||
progressState.speedSamples = progressState.speedSamples[len(progressState.speedSamples)-100:]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -432,9 +443,20 @@ func executeRestoreWithTUIProgress(parentCtx context.Context, cfg *config.Config
|
||||
// STEP 3: Execute restore based on type
|
||||
var restoreErr error
|
||||
if restoreType == "restore-cluster" {
|
||||
restoreErr = engine.RestoreCluster(ctx, archive.Path)
|
||||
// Use pre-extracted directory if available (optimization)
|
||||
if archive.ExtractedDir != "" {
|
||||
log.Info("Using pre-extracted cluster directory", "path", archive.ExtractedDir)
|
||||
defer os.RemoveAll(archive.ExtractedDir) // Cleanup after restore completes
|
||||
restoreErr = engine.RestoreCluster(ctx, archive.Path, archive.ExtractedDir)
|
||||
} else {
|
||||
restoreErr = engine.RestoreCluster(ctx, archive.Path)
|
||||
}
|
||||
} else if restoreType == "restore-cluster-single" {
|
||||
// Restore single database from cluster backup
|
||||
// Also cleanup pre-extracted dir if present
|
||||
if archive.ExtractedDir != "" {
|
||||
defer os.RemoveAll(archive.ExtractedDir)
|
||||
}
|
||||
restoreErr = engine.RestoreSingleFromCluster(ctx, archive.Path, targetDB, targetDB, cleanFirst, createIfMissing)
|
||||
} else {
|
||||
restoreErr = engine.RestoreSingle(ctx, archive.Path, targetDB, cleanFirst, createIfMissing)
|
||||
|
||||
@ -367,6 +367,11 @@ type ArchiveStats struct {
|
||||
TotalSize int64 `json:"total_size"`
|
||||
OldestArchive time.Time `json:"oldest_archive"`
|
||||
NewestArchive time.Time `json:"newest_archive"`
|
||||
OldestWAL string `json:"oldest_wal,omitempty"`
|
||||
NewestWAL string `json:"newest_wal,omitempty"`
|
||||
TimeSpan string `json:"time_span,omitempty"`
|
||||
AvgFileSize int64 `json:"avg_file_size,omitempty"`
|
||||
CompressionRate float64 `json:"compression_rate,omitempty"`
|
||||
}
|
||||
|
||||
// FormatSize returns human-readable size
|
||||
@ -389,3 +394,199 @@ func (s *ArchiveStats) FormatSize() string {
|
||||
return fmt.Sprintf("%d B", s.TotalSize)
|
||||
}
|
||||
}
|
||||
|
||||
// GetArchiveStats scans a WAL archive directory and returns comprehensive statistics
|
||||
func GetArchiveStats(archiveDir string) (*ArchiveStats, error) {
|
||||
stats := &ArchiveStats{
|
||||
OldestArchive: time.Now(),
|
||||
NewestArchive: time.Time{},
|
||||
}
|
||||
|
||||
// Check if directory exists
|
||||
if _, err := os.Stat(archiveDir); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("archive directory does not exist: %s", archiveDir)
|
||||
}
|
||||
|
||||
type walFileInfo struct {
|
||||
name string
|
||||
size int64
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
var walFiles []walFileInfo
|
||||
var compressedSize int64
|
||||
var originalSize int64
|
||||
|
||||
// Walk the archive directory
|
||||
err := filepath.Walk(archiveDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil // Skip files we can't read
|
||||
}
|
||||
|
||||
// Skip directories
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if this is a WAL file (including compressed/encrypted variants)
|
||||
name := info.Name()
|
||||
if !isWALFileName(name) {
|
||||
return nil
|
||||
}
|
||||
|
||||
stats.TotalFiles++
|
||||
stats.TotalSize += info.Size()
|
||||
|
||||
// Track compressed/encrypted files
|
||||
if strings.HasSuffix(name, ".gz") || strings.HasSuffix(name, ".zst") || strings.HasSuffix(name, ".lz4") {
|
||||
stats.CompressedFiles++
|
||||
compressedSize += info.Size()
|
||||
// Estimate original size (WAL files are typically 16MB)
|
||||
originalSize += 16 * 1024 * 1024
|
||||
}
|
||||
if strings.HasSuffix(name, ".enc") || strings.Contains(name, ".encrypted") {
|
||||
stats.EncryptedFiles++
|
||||
}
|
||||
|
||||
// Track oldest/newest
|
||||
if info.ModTime().Before(stats.OldestArchive) {
|
||||
stats.OldestArchive = info.ModTime()
|
||||
stats.OldestWAL = name
|
||||
}
|
||||
if info.ModTime().After(stats.NewestArchive) {
|
||||
stats.NewestArchive = info.ModTime()
|
||||
stats.NewestWAL = name
|
||||
}
|
||||
|
||||
// Store file info for additional calculations
|
||||
walFiles = append(walFiles, walFileInfo{
|
||||
name: name,
|
||||
size: info.Size(),
|
||||
modTime: info.ModTime(),
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan archive directory: %w", err)
|
||||
}
|
||||
|
||||
// Return early if no WAL files found
|
||||
if stats.TotalFiles == 0 {
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// Calculate average file size
|
||||
stats.AvgFileSize = stats.TotalSize / int64(stats.TotalFiles)
|
||||
|
||||
// Calculate compression rate if we have compressed files
|
||||
if stats.CompressedFiles > 0 && originalSize > 0 {
|
||||
stats.CompressionRate = (1.0 - float64(compressedSize)/float64(originalSize)) * 100.0
|
||||
}
|
||||
|
||||
// Calculate time span
|
||||
duration := stats.NewestArchive.Sub(stats.OldestArchive)
|
||||
stats.TimeSpan = formatDuration(duration)
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// isWALFileName checks if a filename looks like a PostgreSQL WAL file
|
||||
func isWALFileName(name string) bool {
|
||||
// Strip compression/encryption extensions
|
||||
baseName := name
|
||||
baseName = strings.TrimSuffix(baseName, ".gz")
|
||||
baseName = strings.TrimSuffix(baseName, ".zst")
|
||||
baseName = strings.TrimSuffix(baseName, ".lz4")
|
||||
baseName = strings.TrimSuffix(baseName, ".enc")
|
||||
baseName = strings.TrimSuffix(baseName, ".encrypted")
|
||||
|
||||
// PostgreSQL WAL files are 24 hex characters (e.g., 000000010000000000000001)
|
||||
// Also accept .backup and .history files
|
||||
if len(baseName) == 24 {
|
||||
// Check if all hex
|
||||
for _, c := range baseName {
|
||||
if !((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Accept .backup and .history files
|
||||
if strings.HasSuffix(baseName, ".backup") || strings.HasSuffix(baseName, ".history") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// formatDuration formats a duration into a human-readable string
|
||||
func formatDuration(d time.Duration) string {
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("%.0f minutes", d.Minutes())
|
||||
}
|
||||
if d < 24*time.Hour {
|
||||
return fmt.Sprintf("%.1f hours", d.Hours())
|
||||
}
|
||||
days := d.Hours() / 24
|
||||
if days < 30 {
|
||||
return fmt.Sprintf("%.1f days", days)
|
||||
}
|
||||
if days < 365 {
|
||||
return fmt.Sprintf("%.1f months", days/30)
|
||||
}
|
||||
return fmt.Sprintf("%.1f years", days/365)
|
||||
}
|
||||
|
||||
// FormatArchiveStats formats archive statistics for display
|
||||
func FormatArchiveStats(stats *ArchiveStats) string {
|
||||
if stats.TotalFiles == 0 {
|
||||
return " No WAL files found in archive"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf(" Total Files: %d\n", stats.TotalFiles))
|
||||
sb.WriteString(fmt.Sprintf(" Total Size: %s\n", stats.FormatSize()))
|
||||
|
||||
if stats.AvgFileSize > 0 {
|
||||
const (
|
||||
KB = 1024
|
||||
MB = 1024 * KB
|
||||
)
|
||||
avgSize := float64(stats.AvgFileSize)
|
||||
if avgSize >= MB {
|
||||
sb.WriteString(fmt.Sprintf(" Average Size: %.2f MB\n", avgSize/MB))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(" Average Size: %.2f KB\n", avgSize/KB))
|
||||
}
|
||||
}
|
||||
|
||||
if stats.CompressedFiles > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" Compressed: %d files", stats.CompressedFiles))
|
||||
if stats.CompressionRate > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (%.1f%% saved)", stats.CompressionRate))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if stats.EncryptedFiles > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" Encrypted: %d files\n", stats.EncryptedFiles))
|
||||
}
|
||||
|
||||
if stats.OldestWAL != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n Oldest WAL: %s\n", stats.OldestWAL))
|
||||
sb.WriteString(fmt.Sprintf(" Created: %s\n", stats.OldestArchive.Format("2006-01-02 15:04:05")))
|
||||
}
|
||||
if stats.NewestWAL != "" {
|
||||
sb.WriteString(fmt.Sprintf(" Newest WAL: %s\n", stats.NewestWAL))
|
||||
sb.WriteString(fmt.Sprintf(" Created: %s\n", stats.NewestArchive.Format("2006-01-02 15:04:05")))
|
||||
}
|
||||
if stats.TimeSpan != "" {
|
||||
sb.WriteString(fmt.Sprintf(" Time Span: %s\n", stats.TimeSpan))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
package wal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/klauspost/pgzip"
|
||||
|
||||
"dbbackup/internal/fs"
|
||||
"dbbackup/internal/logger"
|
||||
|
||||
"github.com/klauspost/pgzip"
|
||||
)
|
||||
|
||||
// Compressor handles WAL file compression
|
||||
@ -26,6 +28,11 @@ func NewCompressor(log logger.Logger) *Compressor {
|
||||
// CompressWALFile compresses a WAL file using parallel gzip (pgzip)
|
||||
// Returns the path to the compressed file and the compressed size
|
||||
func (c *Compressor) CompressWALFile(sourcePath, destPath string, level int) (int64, error) {
|
||||
return c.CompressWALFileContext(context.Background(), sourcePath, destPath, level)
|
||||
}
|
||||
|
||||
// CompressWALFileContext compresses a WAL file with context for cancellation support
|
||||
func (c *Compressor) CompressWALFileContext(ctx context.Context, sourcePath, destPath string, level int) (int64, error) {
|
||||
c.log.Debug("Compressing WAL file", "source", sourcePath, "dest", destPath, "level", level)
|
||||
|
||||
// Open source file
|
||||
@ -56,8 +63,8 @@ func (c *Compressor) CompressWALFile(sourcePath, destPath string, level int) (in
|
||||
}
|
||||
defer gzWriter.Close()
|
||||
|
||||
// Copy and compress
|
||||
_, err = io.Copy(gzWriter, srcFile)
|
||||
// Copy and compress with context support
|
||||
_, err = fs.CopyWithContext(ctx, gzWriter, srcFile)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("compression failed: %w", err)
|
||||
}
|
||||
@ -91,6 +98,11 @@ func (c *Compressor) CompressWALFile(sourcePath, destPath string, level int) (in
|
||||
|
||||
// DecompressWALFile decompresses a gzipped WAL file
|
||||
func (c *Compressor) DecompressWALFile(sourcePath, destPath string) (int64, error) {
|
||||
return c.DecompressWALFileContext(context.Background(), sourcePath, destPath)
|
||||
}
|
||||
|
||||
// DecompressWALFileContext decompresses a gzipped WAL file with context for cancellation
|
||||
func (c *Compressor) DecompressWALFileContext(ctx context.Context, sourcePath, destPath string) (int64, error) {
|
||||
c.log.Debug("Decompressing WAL file", "source", sourcePath, "dest", destPath)
|
||||
|
||||
// Open compressed source file
|
||||
@ -114,9 +126,10 @@ func (c *Compressor) DecompressWALFile(sourcePath, destPath string) (int64, erro
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
// Decompress
|
||||
written, err := io.Copy(dstFile, gzReader)
|
||||
// Decompress with context support
|
||||
written, err := fs.CopyWithContext(ctx, dstFile, gzReader)
|
||||
if err != nil {
|
||||
os.Remove(destPath) // Clean up partial file
|
||||
return 0, fmt.Errorf("decompression failed: %w", err)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user