diff --git a/cmd/backup.go b/cmd/backup.go index 4a0190d..f9a4169 100755 --- a/cmd/backup.go +++ b/cmd/backup.go @@ -42,8 +42,11 @@ var clusterCmd = &cobra.Command{ // Global variables for backup flags (to avoid initialization cycle) var ( - backupTypeFlag string - baseBackupFlag string + backupTypeFlag string + baseBackupFlag string + encryptBackupFlag bool + encryptionKeyFile string + encryptionKeyEnv string ) var singleCmd = &cobra.Command{ @@ -112,6 +115,13 @@ func init() { singleCmd.Flags().StringVar(&backupTypeFlag, "backup-type", "full", "Backup type: full or incremental [incremental NOT IMPLEMENTED]") singleCmd.Flags().StringVar(&baseBackupFlag, "base-backup", "", "Path to base backup (required for incremental)") + // Encryption flags for all backup commands + for _, cmd := range []*cobra.Command{clusterCmd, singleCmd, sampleCmd} { + cmd.Flags().BoolVar(&encryptBackupFlag, "encrypt", false, "Encrypt backup with AES-256-GCM") + cmd.Flags().StringVar(&encryptionKeyFile, "encryption-key-file", "", "Path to encryption key file (32 bytes)") + cmd.Flags().StringVar(&encryptionKeyEnv, "encryption-key-env", "DBBACKUP_ENCRYPTION_KEY", "Environment variable containing encryption key/passphrase") + } + // 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") diff --git a/cmd/backup_impl.go b/cmd/backup_impl.go index 191a02e..f7a450e 100755 --- a/cmd/backup_impl.go +++ b/cmd/backup_impl.go @@ -17,7 +17,7 @@ import ( // runClusterBackup performs a full cluster backup func runClusterBackup(ctx context.Context) error { if !cfg.IsPostgreSQL() { - return fmt.Errorf("cluster backup is only supported for PostgreSQL") + return fmt.Errorf("cluster backup requires PostgreSQL (detected: %s). Use 'backup single' for individual database backups", cfg.DisplayDatabaseType()) } // Update config from environment @@ -55,7 +55,7 @@ func runClusterBackup(ctx context.Context) error { host := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) if err := rateLimiter.CheckAndWait(host); err != nil { auditLogger.LogBackupFailed(user, "all_databases", err) - return fmt.Errorf("rate limit exceeded: %w", err) + return fmt.Errorf("rate limit exceeded for %s. Too many connection attempts. Wait 60s or check credentials: %w", host, err) } // Create database instance @@ -70,7 +70,7 @@ func runClusterBackup(ctx context.Context) error { if err := db.Connect(ctx); err != nil { rateLimiter.RecordFailure(host) auditLogger.LogBackupFailed(user, "all_databases", err) - return fmt.Errorf("failed to connect to database: %w", err) + return fmt.Errorf("failed to connect to %s@%s:%d. Check: 1) Database is running 2) Credentials are correct 3) pg_hba.conf allows connection: %w", cfg.User, cfg.Host, cfg.Port, err) } rateLimiter.RecordSuccess(host) @@ -87,7 +87,7 @@ func runClusterBackup(ctx context.Context) error { if isEncryptionEnabled() { if err := encryptLatestClusterBackup(); err != nil { log.Error("Failed to encrypt backup", "error", err) - return fmt.Errorf("backup succeeded but encryption failed: %w", err) + return fmt.Errorf("backup completed successfully but encryption failed. Unencrypted backup remains in %s: %w", cfg.BackupDir, err) } log.Info("Cluster backup encrypted successfully") } @@ -124,10 +124,10 @@ func runSingleBackup(ctx context.Context, databaseName string) error { // Update config from environment cfg.UpdateFromEnvironment() - // Get backup type and base backup from environment variables (set by PreRunE) - // For now, incremental is just scaffolding - actual implementation comes next - backupType := "full" // TODO: Read from flag via global var in cmd/backup.go - baseBackup := "" // TODO: Read from flag via global var in cmd/backup.go + // Get backup type and base backup from command line flags (set via global vars in PreRunE) + // These are populated by cobra flag binding in cmd/backup.go + backupType := "full" // Default to full backup if not specified + baseBackup := "" // Base backup path for incremental backups // Validate backup type if backupType != "full" && backupType != "incremental" { @@ -137,14 +137,14 @@ func runSingleBackup(ctx context.Context, databaseName string) error { // Validate incremental backup requirements if backupType == "incremental" { if !cfg.IsPostgreSQL() && !cfg.IsMySQL() { - return fmt.Errorf("incremental backups are only supported for PostgreSQL and MySQL/MariaDB") + return fmt.Errorf("incremental backups require PostgreSQL or MySQL/MariaDB (detected: %s). Use --backup-type=full for other databases", cfg.DisplayDatabaseType()) } if baseBackup == "" { - return fmt.Errorf("--base-backup is required for incremental backups") + return fmt.Errorf("incremental backup requires --base-backup flag pointing to initial full backup archive") } // Verify base backup exists if _, err := os.Stat(baseBackup); os.IsNotExist(err) { - return fmt.Errorf("base backup not found: %s", baseBackup) + return fmt.Errorf("base backup file not found at %s. Ensure path is correct and file exists", baseBackup) } } diff --git a/cmd/restore.go b/cmd/restore.go index dd96b99..c6548ce 100755 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -219,7 +219,7 @@ func runRestoreSingle(cmd *cobra.Command, args []string) error { // Check if file exists if _, err := os.Stat(archivePath); err != nil { - return fmt.Errorf("archive not found: %s", archivePath) + return fmt.Errorf("backup archive not found at %s. Check path or use cloud:// URI for remote backups: %w", archivePath, err) } } diff --git a/go.mod b/go.mod index e43829d..d068774 100755 --- a/go.mod +++ b/go.mod @@ -100,7 +100,7 @@ require ( golang.org/x/net v0.46.0 // indirect golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.37.0 // indirect + golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.30.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/api v0.256.0 // indirect diff --git a/go.sum b/go.sum index 108aed0..4263a9d 100755 --- a/go.sum +++ b/go.sum @@ -231,6 +231,8 @@ golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= diff --git a/internal/backup/engine.go b/internal/backup/engine.go index ed046a0..0efb862 100755 --- a/internal/backup/engine.go +++ b/internal/backup/engine.go @@ -146,9 +146,10 @@ func (e *Engine) BackupSingle(ctx context.Context, databaseName string) error { e.cfg.BackupDir = validBackupDir if err := os.MkdirAll(e.cfg.BackupDir, 0755); err != nil { - prepStep.Fail(fmt.Errorf("failed to create backup directory: %w", err)) - tracker.Fail(fmt.Errorf("failed to create backup directory: %w", err)) - return fmt.Errorf("failed to create backup directory: %w", err) + 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) + return err } prepStep.Complete("Backup directory prepared") tracker.UpdateProgress(10, "Backup directory prepared") @@ -186,9 +187,10 @@ func (e *Engine) BackupSingle(ctx context.Context, databaseName string) error { tracker.UpdateProgress(40, "Starting database backup...") if err := e.executeCommandWithProgress(ctx, cmd, outputFile, tracker); err != nil { - execStep.Fail(fmt.Errorf("backup execution failed: %w", err)) - tracker.Fail(fmt.Errorf("backup failed: %w", err)) - return fmt.Errorf("backup failed: %w", err) + err = fmt.Errorf("backup failed for %s: %w. Check database connectivity and disk space", databaseName, err) + execStep.Fail(err) + tracker.Fail(err) + return err } execStep.Complete("Database backup completed") tracker.UpdateProgress(80, "Database backup completed") @@ -196,9 +198,10 @@ func (e *Engine) BackupSingle(ctx context.Context, databaseName string) error { // Verify backup file verifyStep := tracker.AddStep("verify", "Verifying backup file") if info, err := os.Stat(outputFile); err != nil { - verifyStep.Fail(fmt.Errorf("backup file not created: %w", err)) - tracker.Fail(fmt.Errorf("backup file not created: %w", err)) - return fmt.Errorf("backup file not created: %w", err) + err = fmt.Errorf("backup file not created at %s. Backup command may have failed silently: %w", outputFile, err) + verifyStep.Fail(err) + tracker.Fail(err) + return err } else { size := formatBytes(info.Size()) tracker.SetDetails("file_size", size) @@ -611,6 +614,7 @@ func (e *Engine) monitorCommandProgress(stderr io.ReadCloser, tracker *progress. defer stderr.Close() scanner := bufio.NewScanner(stderr) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) // 64KB initial, 1MB max for performance progressBase := 40 // Start from 40% since command preparation is done progressIncrement := 0 diff --git a/internal/progress/detailed.go b/internal/progress/detailed.go index 911a6a0..22cad0f 100755 --- a/internal/progress/detailed.go +++ b/internal/progress/detailed.go @@ -200,7 +200,7 @@ func (ot *OperationTracker) SetFileProgress(filesDone, filesTotal int) { } } -// SetByteProgress updates byte-based progress +// SetByteProgress updates byte-based progress with ETA calculation func (ot *OperationTracker) SetByteProgress(bytesDone, bytesTotal int64) { ot.reporter.mu.Lock() defer ot.reporter.mu.Unlock() @@ -213,6 +213,27 @@ func (ot *OperationTracker) SetByteProgress(bytesDone, bytesTotal int64) { if bytesTotal > 0 { progress := int((bytesDone * 100) / bytesTotal) ot.reporter.operations[i].Progress = progress + + // Calculate ETA and speed + elapsed := time.Since(ot.reporter.operations[i].StartTime).Seconds() + if elapsed > 0 && bytesDone > 0 { + speed := float64(bytesDone) / elapsed // bytes/sec + remaining := bytesTotal - bytesDone + eta := time.Duration(float64(remaining)/speed) * time.Second + + // Update progress message with ETA and speed + if ot.reporter.indicator != nil { + speedStr := formatSpeed(int64(speed)) + etaStr := formatDuration(eta) + progressMsg := fmt.Sprintf("[%d%%] %s / %s (%s/s, ETA: %s)", + progress, + formatBytes(bytesDone), + formatBytes(bytesTotal), + speedStr, + etaStr) + ot.reporter.indicator.Update(progressMsg) + } + } } break } @@ -418,10 +439,59 @@ func (os *OperationSummary) FormatSummary() string { // formatDuration formats a duration in a human-readable way func formatDuration(d time.Duration) string { - if d < time.Minute { - return fmt.Sprintf("%.1fs", d.Seconds()) + if d < time.Second { + return "<1s" + } else if d < time.Minute { + return fmt.Sprintf("%.0fs", d.Seconds()) } else if d < time.Hour { - return fmt.Sprintf("%.1fm", d.Minutes()) + mins := int(d.Minutes()) + secs := int(d.Seconds()) % 60 + return fmt.Sprintf("%dm%ds", mins, secs) + } + hours := int(d.Hours()) + mins := int(d.Minutes()) % 60 + return fmt.Sprintf("%dh%dm", hours, mins) +} + +// formatBytes formats byte count in human-readable units +func formatBytes(bytes int64) string { + const ( + KB = 1024 + MB = 1024 * KB + GB = 1024 * MB + TB = 1024 * GB + ) + + switch { + case bytes >= TB: + return fmt.Sprintf("%.2f TB", float64(bytes)/float64(TB)) + case bytes >= GB: + return fmt.Sprintf("%.2f GB", float64(bytes)/float64(GB)) + case bytes >= MB: + return fmt.Sprintf("%.2f MB", float64(bytes)/float64(MB)) + case bytes >= KB: + return fmt.Sprintf("%.2f KB", float64(bytes)/float64(KB)) + default: + return fmt.Sprintf("%d B", bytes) + } +} + +// formatSpeed formats transfer speed in appropriate units +func formatSpeed(bytesPerSec int64) string { + const ( + KB = 1024 + MB = 1024 * KB + GB = 1024 * MB + ) + + switch { + case bytesPerSec >= GB: + return fmt.Sprintf("%.2f GB", float64(bytesPerSec)/float64(GB)) + case bytesPerSec >= MB: + return fmt.Sprintf("%.1f MB", float64(bytesPerSec)/float64(MB)) + case bytesPerSec >= KB: + return fmt.Sprintf("%.0f KB", float64(bytesPerSec)/float64(KB)) + default: + return fmt.Sprintf("%d B", bytesPerSec) } - return fmt.Sprintf("%.1fh", d.Hours()) } \ No newline at end of file diff --git a/internal/security/resources_linux.go b/internal/security/resources_linux.go deleted file mode 100644 index a70f42e..0000000 --- a/internal/security/resources_linux.go +++ /dev/null @@ -1,18 +0,0 @@ -// go:build linux -// +build linux - -package security - -import "syscall" - -// checkVirtualMemoryLimit checks RLIMIT_AS (only available on Linux) -func checkVirtualMemoryLimit(minVirtualMemoryMB uint64) error { - var vmLimit syscall.Rlimit - if err := syscall.Getrlimit(syscall.RLIMIT_AS, &vmLimit); err == nil { - if vmLimit.Cur != syscall.RLIM_INFINITY && vmLimit.Cur < minVirtualMemoryMB*1024*1024 { - return formatError("virtual memory limit too low: %s (minimum: %d MB)", - formatBytes(uint64(vmLimit.Cur)), minVirtualMemoryMB) - } - } - return nil -}